diff --git a/.github/scripts/run-studio-permission-browser.sh b/.github/scripts/run-studio-permission-browser.sh index 2007789035..e5a9a4c135 100755 --- a/.github/scripts/run-studio-permission-browser.sh +++ b/.github/scripts/run-studio-permission-browser.sh @@ -17,7 +17,8 @@ if [ -n "${STUDIO_PERMISSION_FRONTEND:-}" ]; then fi mkdir -p "$artifact_dir" -unsloth studio reset-password +# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. +rm -rf "$studio_home/auth" UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$port" "$@" \ >"$server_log" 2>&1 & studio_pid=$! diff --git a/.github/workflows/studio-api-smoke.yml b/.github/workflows/studio-api-smoke.yml index cdf1f6bf12..1cfa66fea4 100644 --- a/.github/workflows/studio-api-smoke.yml +++ b/.github/workflows/studio-api-smoke.yml @@ -113,7 +113,8 @@ jobs: - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & diff --git a/.github/workflows/studio-inference-smoke.yml b/.github/workflows/studio-inference-smoke.yml index c2d52eac22..c37c9555bf 100644 --- a/.github/workflows/studio-inference-smoke.yml +++ b/.github/workflows/studio-inference-smoke.yml @@ -127,7 +127,8 @@ jobs: - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -400,7 +401,7 @@ jobs: # tool_policy=None so each request's `enable_tools` field is # honoured. run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -978,7 +979,7 @@ jobs: # response_format requests aren't routed through the agentic # tool loop. run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & diff --git a/.github/workflows/studio-mac-api-smoke.yml b/.github/workflows/studio-mac-api-smoke.yml index 1968885a1d..c2307f17a1 100644 --- a/.github/workflows/studio-mac-api-smoke.yml +++ b/.github/workflows/studio-mac-api-smoke.yml @@ -101,7 +101,8 @@ jobs: - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & diff --git a/.github/workflows/studio-mac-inference-smoke.yml b/.github/workflows/studio-mac-inference-smoke.yml index ce15eed5c8..1dbf86ae98 100644 --- a/.github/workflows/studio-mac-inference-smoke.yml +++ b/.github/workflows/studio-mac-inference-smoke.yml @@ -126,7 +126,8 @@ jobs: - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -386,7 +387,7 @@ jobs: # tool_policy=None so each request's `enable_tools` field is # honoured. run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -831,7 +832,7 @@ jobs: # response_format requests aren't routed through the agentic # tool loop. run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & diff --git a/.github/workflows/studio-mac-ui-smoke.yml b/.github/workflows/studio-mac-ui-smoke.yml index 7375e9bcbf..3bed2fcdff 100644 --- a/.github/workflows/studio-mac-ui-smoke.yml +++ b/.github/workflows/studio-mac-ui-smoke.yml @@ -146,7 +146,8 @@ jobs: - name: Reset auth + boot Unsloth run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -190,7 +191,7 @@ jobs: # runner's kernel briefly runs out of socket buffers, and (3) a # goto 'interrupted by another navigation' when the SPA auth # guard redirects mid-navigation. The retry FULLY resets Unsloth - # (kill, reset-password, reboot, wait /api/health, re-export + # (kill, wipe auth, reboot, wait /api/health, re-export # bootstrap pw) before re-running the script. A real test failure # (assertion / timeout) does NOT match any pattern so it bypasses # retry and surfaces immediately. @@ -213,7 +214,7 @@ jobs: echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..." kill "${STUDIO_PID}" 2>/dev/null || true sleep 2 - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > "logs/studio_retry_${attempt}.log" 2>&1 & STUDIO_PID=$! @@ -251,7 +252,7 @@ jobs: - name: Reset auth + boot Unsloth for extra UI tests (port 18897) run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \ > logs/studio_extra.log 2>&1 & @@ -308,7 +309,7 @@ jobs: echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..." kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true sleep 2 - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \ > "logs/studio_extra_retry_${attempt}.log" 2>&1 & STUDIO_EXTRA_PID=$! diff --git a/.github/workflows/studio-ui-smoke.yml b/.github/workflows/studio-ui-smoke.yml index 97eb07b2d8..3a0713f301 100644 --- a/.github/workflows/studio-ui-smoke.yml +++ b/.github/workflows/studio-ui-smoke.yml @@ -115,7 +115,8 @@ jobs: - name: Reset auth + boot Unsloth run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -193,7 +194,7 @@ jobs: # warm install we already did) so this adds little wall time. - name: Reset auth + boot Unsloth for extra UI tests (port 18894) run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18894 \ > logs/studio_extra.log 2>&1 & @@ -253,7 +254,7 @@ jobs: # (RAG embedder + llama.cpp probe) stay hidden from the picker. - name: Reset auth + boot Unsloth for model-config tests (port 18898) run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18898 \ > logs/studio_modelcfg.log 2>&1 & @@ -299,7 +300,7 @@ jobs: # earlier UI tests. No GGUF -- the bug surface is the composer. - name: Reset auth + boot Unsloth for IME / i18n tests (port 18896) run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18896 \ > logs/studio_ime.log 2>&1 & diff --git a/.github/workflows/studio-windows-api-smoke.yml b/.github/workflows/studio-windows-api-smoke.yml index 6dbcceebbd..b328939846 100644 --- a/.github/workflows/studio-windows-api-smoke.yml +++ b/.github/workflows/studio-windows-api-smoke.yml @@ -179,7 +179,8 @@ jobs: - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & diff --git a/.github/workflows/studio-windows-inference-smoke.yml b/.github/workflows/studio-windows-inference-smoke.yml index b3badef02b..d821664327 100644 --- a/.github/workflows/studio-windows-inference-smoke.yml +++ b/.github/workflows/studio-windows-inference-smoke.yml @@ -229,7 +229,8 @@ jobs: - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -573,7 +574,7 @@ jobs: - name: Reset auth + boot Unsloth (API-only, default tool policy) run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -1074,7 +1075,7 @@ jobs: - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -1546,7 +1547,7 @@ jobs: - name: Reset auth + boot Unsloth (API-only) run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & diff --git a/.github/workflows/studio-windows-ui-smoke.yml b/.github/workflows/studio-windows-ui-smoke.yml index f401f7be44..d23cca323f 100644 --- a/.github/workflows/studio-windows-ui-smoke.yml +++ b/.github/workflows/studio-windows-ui-smoke.yml @@ -297,7 +297,8 @@ jobs: - name: Reset auth + boot Unsloth run: | - unsloth studio reset-password + # Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password. + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \ > logs/studio.log 2>&1 & @@ -352,7 +353,7 @@ jobs: - name: Reset auth + boot Unsloth for extra UI tests (port 18897) run: | - unsloth studio reset-password + rm -rf ~/.unsloth/studio/auth mkdir -p logs UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \ > logs/studio_extra.log 2>&1 & diff --git a/studio/backend/auth/authentication.py b/studio/backend/auth/authentication.py index 94df994928..2e9520827e 100644 --- a/studio/backend/auth/authentication.py +++ b/studio/backend/auth/authentication.py @@ -11,11 +11,12 @@ import jwt from .storage import ( API_KEY_PREFIX, + credential_generation, get_jwt_secret, get_user_and_secret, load_jwt_secret, save_refresh_token, - validate_api_key, + validate_api_key_with_credential, verify_refresh_token, ) @@ -54,11 +55,14 @@ def create_access_token( expires_delta: Optional[timedelta] = None, *, desktop: bool = False, + secret: Optional[str] = None, ) -> str: """ Create a signed JWT for the given subject (e.g. username). - Valid across restarts: the signing secret is stored in SQLite. + Valid across restarts: the signing secret is stored in SQLite. Callers that + already verified a credential pass ``secret`` so a rotation landing mid-request + cannot sign the token with the credential that just replaced it. """ to_encode = {"sub": subject} if desktop: @@ -69,7 +73,7 @@ def create_access_token( to_encode.update({"exp": expire}) return jwt.encode( to_encode, - _get_secret_for_subject(subject), + secret if secret is not None else _get_secret_for_subject(subject), algorithm = ALGORITHM, ) @@ -96,15 +100,28 @@ def is_desktop_access_token(token: str) -> bool: return payload.get("sub") == subject and payload.get("desktop") is True -def create_refresh_token(subject: str, *, desktop: bool = False) -> str: +def create_refresh_token( + subject: str, + *, + desktop: bool = False, + secret: Optional[str] = None, +) -> 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. + ``secret`` stamps the token with the credential version the caller verified, + so a rotation cannot leave a token minted from the replaced credential valid. """ 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) + save_refresh_token( + token, + subject, + expires_at.isoformat(), + is_desktop = desktop, + secret_gen = credential_generation(secret) if secret is not None else None, + ) return token @@ -137,7 +154,22 @@ def reload_secret() -> None: 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( + subject, _generation = await _get_current_credential( + credentials, + allow_password_change = False, + ) + return subject + + +async def get_current_credential( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> Tuple[str, Optional[str]]: + """As get_current_subject, but also returns the credential generation. + + For routes that persist a new credential and must not do so on behalf of one + a concurrent reset has revoked. + """ + return await _get_current_credential( credentials, allow_password_change = False, ) @@ -158,10 +190,11 @@ 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( + subject, _generation = await _get_current_credential( credentials, allow_password_change = True, ) + return subject # The literal the examples ship with; pasted unedited more often than a revoked key. @@ -179,21 +212,27 @@ def _invalid_api_key_detail(token: str) -> str: return "Invalid or expired API key" -async def _get_current_subject( +async def _get_current_credential( credentials: HTTPAuthorizationCredentials, *, allow_password_change: bool -) -> str: - """FastAPI dependency: validate the JWT and return the subject. Use on protected routes.""" +) -> Tuple[str, Optional[str]]: + """Validate the bearer and return ``(subject, credential generation)``. + + The generation is the credential version this request actually authenticated + against. Routes that persist new credentials must bind their write to it, or + a reset landing mid-request would bless what it just revoked. + """ token = credentials.credentials # --- API key path (sk-unsloth-...) --- if token.startswith(API_KEY_PREFIX): - username = validate_api_key(token) - if username is None: + verified = validate_api_key_with_credential(token) + if verified is None: raise HTTPException( status_code = status.HTTP_401_UNAUTHORIZED, detail = _invalid_api_key_detail(token), ) - return username + username, secret = verified + return username, credential_generation(secret) # --- JWT path --- subject = _decode_subject_without_verification(token) @@ -224,7 +263,7 @@ async def _get_current_subject( status_code = status.HTTP_403_FORBIDDEN, detail = "Password change required", ) - return subject + return subject, credential_generation(jwt_secret) except jwt.InvalidTokenError: raise HTTPException( status_code = status.HTTP_401_UNAUTHORIZED, diff --git a/studio/backend/auth/storage.py b/studio/backend/auth/storage.py index 9702827725..6cf4d44834 100644 --- a/studio/backend/auth/storage.py +++ b/studio/backend/auth/storage.py @@ -186,7 +186,7 @@ def clear_bootstrap_password() -> None: # Removal failed (Windows AV, read-only auth dir). The hash is already # committed, so don't fail the change -- but truncate the file so its # stale plaintext can't be re-seeded by generate_bootstrap_password() - # if a later reset-password deletes auth.db and re-validates it. + # if auth.db is ever recreated. try: _BOOTSTRAP_PW_PATH.write_text("", encoding = "utf-8") cleared = True @@ -221,6 +221,31 @@ def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() +class CredentialRotated(Exception): + """A password reset revoked the credential this request authenticated with.""" + + +def credential_generation(jwt_secret: str) -> str: + """Marker for the credential version a refresh token was issued under. + + Every password change rotates ``jwt_secret``, so a token stamped with the + previous one is rejected even if it was inserted after the revoking DELETE. + """ + return hashlib.sha256(jwt_secret.encode("utf-8")).hexdigest() + + +def _current_secret(conn: sqlite3.Connection, username: str) -> Optional[str]: + row = conn.execute( + "SELECT jwt_secret FROM auth_user WHERE username = ?", (username,) + ).fetchone() + return row["jwt_secret"] if row else None + + +def _current_generation(conn: sqlite3.Connection, username: str) -> Optional[str]: + secret = _current_secret(conn, username) + return credential_generation(secret) if secret is not None else None + + def get_connection() -> sqlite3.Connection: """Get a connection to the auth database, creating tables if needed.""" ensure_dir(DB_PATH.parent) @@ -264,7 +289,8 @@ def get_connection() -> sqlite3.Connection: token_hash TEXT NOT NULL, username TEXT NOT NULL, expires_at TEXT NOT NULL, - is_desktop INTEGER NOT NULL DEFAULT 0 + is_desktop INTEGER NOT NULL DEFAULT 0, + secret_gen TEXT ); """ ) @@ -303,6 +329,8 @@ def get_connection() -> sqlite3.Connection: refresh_columns = {row["name"] for row in conn.execute("PRAGMA table_info(refresh_tokens)")} if "is_desktop" not in refresh_columns: conn.execute("ALTER TABLE refresh_tokens ADD COLUMN is_desktop INTEGER NOT NULL DEFAULT 0") + if "secret_gen" not in refresh_columns: + conn.execute("ALTER TABLE refresh_tokens ADD COLUMN secret_gen TEXT") conn.commit() return conn @@ -676,12 +704,22 @@ def update_password( new_password: str, *, revoke_refresh_tokens: bool = False, -) -> bool: + expect_password_hash: Optional[str] = None, +) -> Optional[str]: """Update password, clear first-login requirement, rotate JWT secret. + Returns the new JWT secret, or None when nothing was updated. Callers that + mint tokens for the caller must sign with the returned secret: re-reading it + would pick up a reset that landed between this commit and the mint. + ``revoke_refresh_tokens`` deletes the user's refresh tokens in the SAME transaction: a separate delete could fail after the password commit and leave a pre-change token still able to mint access tokens. + + ``expect_password_hash`` makes the write conditional on the credential the + caller verified still being current, so a request that checked the old + password cannot overwrite a reset that landed while it was in flight. + Returns False when the credential moved underneath it. """ from .hashing import hash_password @@ -689,21 +727,32 @@ def update_password( jwt_secret = secrets.token_urlsafe(64) conn = get_connection() try: - cursor = conn.execute( - """ - UPDATE auth_user - SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0 - WHERE username = ? - """, - (salt, pwd_hash, jwt_secret, username), - ) + if expect_password_hash is None: + cursor = conn.execute( + """ + UPDATE auth_user + SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0 + WHERE username = ? + """, + (salt, pwd_hash, jwt_secret, username), + ) + else: + cursor = conn.execute( + """ + UPDATE auth_user + SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0 + WHERE username = ? AND password_hash = ? + """, + (salt, pwd_hash, jwt_secret, username, expect_password_hash), + ) if revoke_refresh_tokens and cursor.rowcount > 0: conn.execute("DELETE FROM refresh_tokens WHERE username = ?", (username,)) conn.commit() if cursor.rowcount > 0: clear_bootstrap_password() clear_desktop_secret() - return cursor.rowcount > 0 + return jwt_secret + return None finally: conn.close() @@ -714,35 +763,49 @@ def save_refresh_token( expires_at: str, *, is_desktop: bool = False, + secret_gen: Optional[str] = None, ) -> None: """ Store a hashed refresh token with its associated username and expiry. + + ``secret_gen`` binds the token to a credential version; it defaults to the + current one, and callers that already verified a credential must pass the + version they verified rather than let this re-read a rotated one. """ token_hash = _hash_token(token) conn = get_connection() try: + if secret_gen is None: + secret_gen = _current_generation(conn, username) conn.execute( """ - INSERT INTO refresh_tokens (token_hash, username, expires_at, is_desktop) - VALUES (?, ?, ?, ?) + INSERT INTO refresh_tokens (token_hash, username, expires_at, is_desktop, secret_gen) + VALUES (?, ?, ?, ?, ?) """, - (token_hash, username, expires_at, int(is_desktop)), + (token_hash, username, expires_at, int(is_desktop), secret_gen), ) conn.commit() finally: conn.close() -def consume_refresh_token(token: str) -> Optional[Tuple[str, bool]]: +def consume_refresh_token(token: str) -> Optional[Tuple[str, bool, str]]: """Atomically validate-and-delete a refresh token for single-use rotation. DELETE RETURNING fuses validate and delete into one statement so two - concurrent refresh requests cannot both consume the same token. + concurrent refresh requests cannot both consume the same token. Returns + ``(username, is_desktop, jwt_secret)``; the caller must mint the replacement + tokens against that secret so a rotation landing mid-refresh cannot issue a + post-rotation session from a pre-rotation token. """ token_hash = _hash_token(token) now = datetime.now(timezone.utc).isoformat() conn = get_connection() try: + # One transaction with the delete: an unstamped legacy row has no + # generation to compare, so reading the credential after committing would + # hand a reset's new secret to a token issued before it. + conn.execute("BEGIN IMMEDIATE") conn.execute( "DELETE FROM refresh_tokens WHERE expires_at < ?", (now,), @@ -751,15 +814,21 @@ def consume_refresh_token(token: str) -> Optional[Tuple[str, bool]]: """ DELETE FROM refresh_tokens WHERE token_hash = ? AND expires_at >= ? - RETURNING username, is_desktop + RETURNING username, is_desktop, secret_gen """, (token_hash, now), ) row = cur.fetchone() - conn.commit() if row is None: + conn.commit() return None - return row["username"], bool(row["is_desktop"]) + secret = _current_secret(conn, row["username"]) + conn.commit() + if secret is None: + return None + if row["secret_gen"] is not None and row["secret_gen"] != credential_generation(secret): + return None + return row["username"], bool(row["is_desktop"]), secret finally: conn.close() @@ -783,7 +852,7 @@ def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]: cur = conn.execute( """ - SELECT id, username, expires_at, is_desktop FROM refresh_tokens + SELECT id, username, expires_at, is_desktop, secret_gen FROM refresh_tokens WHERE token_hash = ? """, (token_hash,), @@ -792,6 +861,13 @@ def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]: if row is None: return None + if row["secret_gen"] is not None and row["secret_gen"] != _current_generation( + conn, row["username"] + ): + conn.execute("DELETE FROM refresh_tokens WHERE id = ?", (row["id"],)) + conn.commit() + return None + # Check expiry expires_at = datetime.fromisoformat(row["expires_at"]) if datetime.now(timezone.utc) > expires_at: @@ -836,30 +912,41 @@ def create_desktop_secret() -> str: conn.close() -def validate_desktop_secret(raw_secret: str) -> Optional[str]: - """Return the real admin username when the desktop secret matches.""" +def validate_desktop_secret_with_credential(raw_secret: str) -> Optional[Tuple[str, str]]: + """Validate the desktop secret and return ``(username, jwt_secret)``. + + Both reads share one transaction so the returned secret is the credential + version the desktop secret was checked against; a reset landing mid-request + then invalidates the tokens minted from it rather than blessing them. + """ if not raw_secret.startswith(DESKTOP_SECRET_PREFIX): return None - if get_user_and_secret(DEFAULT_ADMIN_USERNAME) is None: - return None secret_hash = _pbkdf2_desktop_secret(raw_secret) conn = get_connection() try: - cur = conn.execute( + conn.execute("BEGIN") + row = conn.execute( "SELECT value FROM app_secrets WHERE key = ?", (_DESKTOP_SECRET_HASH_KEY,), - ) - row = cur.fetchone() - if row is None: + ).fetchone() + if row is None or not secrets.compare_digest(row["value"], secret_hash): return None - if not secrets.compare_digest(row["value"], secret_hash): + jwt_secret = _current_secret(conn, DEFAULT_ADMIN_USERNAME) + if jwt_secret is None: return None - return DEFAULT_ADMIN_USERNAME + return DEFAULT_ADMIN_USERNAME, jwt_secret finally: + conn.rollback() conn.close() +def validate_desktop_secret(raw_secret: str) -> Optional[str]: + """Return the real admin username when the desktop secret matches.""" + verified = validate_desktop_secret_with_credential(raw_secret) + return verified[0] if verified else None + + def clear_desktop_secret() -> None: """Remove backend-side desktop auth state.""" conn = get_connection() @@ -885,6 +972,7 @@ def create_api_key( name: str, expires_at: Optional[str] = None, internal: bool = False, + expect_gen: Optional[str] = None, ) -> Tuple[str, dict]: """Create a new API key for *username*. @@ -893,6 +981,10 @@ def create_api_key( Pass ``internal=True`` for keys minted by workflows (e.g. data-recipe runs) that should not appear in user-facing key listings. + + ``expect_gen`` ties the insert to the credential generation the request + authenticated under, so a session revoked by a concurrent password reset + cannot mint a key that outlives it. Raises ``CredentialRotated`` if it moved. """ raw_key = API_KEY_PREFIX + secrets.token_hex(16) key_hash = _pbkdf2_api_key(raw_key) @@ -901,6 +993,12 @@ def create_api_key( conn = get_connection() try: + if expect_gen is not None: + conn.execute("BEGIN IMMEDIATE") + if _current_generation(conn, username) != expect_gen: + raise CredentialRotated( + "The credential this request authenticated with was revoked." + ) conn.execute( """ INSERT INTO api_keys (username, key_prefix, key_hash, name, created_at, expires_at, is_internal) @@ -989,15 +1087,25 @@ def revoke_internal_api_key(key_id: int) -> bool: def validate_api_key(raw_key: str) -> Optional[str]: - """Validate *raw_key* and return the owning username, or ``None``. + """Validate *raw_key* and return the owning username, or ``None``.""" + verified = validate_api_key_with_credential(raw_key) + return verified[0] if verified else None - Also updates ``last_used_at`` on success. + +def validate_api_key_with_credential(raw_key: str) -> Optional[Tuple[str, str]]: + """Validate *raw_key* and return ``(username, jwt_secret)``, or ``None``. + + Also updates ``last_used_at`` on success. The key check and the credential + read share one write transaction, so the returned version is the one the key + was actually valid under: a reset committing right after cannot have its new + generation handed to a request the key it revoked authenticated. """ cache_id = _api_key_cache_id(raw_key) cached_hash = _api_key_hash_cache.get(cache_id) key_hash = cached_hash if cached_hash is not None else _pbkdf2_api_key(raw_key) conn = get_connection() try: + conn.execute("BEGIN IMMEDIATE") cur = conn.execute( "SELECT id, username, is_active, expires_at FROM api_keys WHERE key_hash = ?", (key_hash,), @@ -1017,11 +1125,15 @@ def validate_api_key(raw_key: str) -> Optional[str]: expires = datetime.fromisoformat(row["expires_at"]) if datetime.now(timezone.utc) > expires: return None + secret = _current_secret(conn, row["username"]) + if secret is None: + return None conn.execute( "UPDATE api_keys SET last_used_at = ? WHERE id = ?", (datetime.now(timezone.utc).isoformat(), row["id"]), ) conn.commit() - return row["username"] + return row["username"], secret finally: + conn.rollback() conn.close() diff --git a/studio/backend/routes/auth.py b/studio/backend/routes/auth.py index 1acc48e3a3..fe2f09fcd9 100644 --- a/studio/backend/routes/auth.py +++ b/studio/backend/routes/auth.py @@ -31,6 +31,7 @@ from auth import storage, hashing from auth.authentication import ( create_access_token, create_refresh_token, + get_current_credential, get_current_subject, get_current_subject_allow_password_change, refresh_access_token, @@ -399,7 +400,7 @@ async def login(payload: AuthLoginRequest, request: Request) -> Token: detail = f"Incorrect password. To reset it, run this in your terminal: {_reset_password_command()}", ) - salt, pwd_hash, _jwt_secret, must_change_password = record + salt, pwd_hash, jwt_secret, must_change_password = record if not hashing.verify_password(payload.password, salt, pwd_hash): _record_login_failure(key) raise HTTPException( @@ -409,8 +410,10 @@ async def login(payload: AuthLoginRequest, request: Request) -> Token: _clear_login_bucket(key) _clear_login_bucket(unknown_key) - access_token = create_access_token(subject = payload.username) - refresh_token = create_refresh_token(subject = payload.username) + # Issue against the credential version just verified, not whatever is in the DB + # now: a concurrent reset-password must not hand this login a post-reset session. + access_token = create_access_token(subject = payload.username, secret = jwt_secret) + refresh_token = create_refresh_token(subject = payload.username, secret = jwt_secret) return Token( access_token = access_token, refresh_token = refresh_token, @@ -438,16 +441,17 @@ async def logout( @router.post("/desktop-login", response_model = Token) async def desktop_login(payload: DesktopLoginRequest) -> Token: """Exchange a local desktop secret for normal admin-subject tokens.""" - username = storage.validate_desktop_secret(payload.secret) - if username is None: + verified = storage.validate_desktop_secret_with_credential(payload.secret) + if verified is None: raise HTTPException( status_code = status.HTTP_401_UNAUTHORIZED, detail = "Desktop authentication failed", ) + username, jwt_secret = verified return Token( - access_token = create_access_token(subject = username, desktop = True), - refresh_token = create_refresh_token(subject = username, desktop = True), + access_token = create_access_token(subject = username, desktop = True, secret = jwt_secret), + refresh_token = create_refresh_token(subject = username, desktop = True, secret = jwt_secret), token_type = "bearer", must_change_password = False, ) @@ -462,9 +466,11 @@ async def refresh(payload: RefreshTokenRequest) -> Token: status_code = status.HTTP_401_UNAUTHORIZED, detail = "Invalid or expired refresh token", ) - username, is_desktop = consumed - new_access_token = create_access_token(subject = username, desktop = is_desktop) - new_refresh_token = create_refresh_token(subject = username, desktop = is_desktop) + username, is_desktop, jwt_secret = consumed + new_access_token = create_access_token(subject = username, desktop = is_desktop, secret = jwt_secret) + new_refresh_token = create_refresh_token( + subject = username, desktop = is_desktop, secret = jwt_secret + ) return Token( access_token = new_access_token, @@ -507,13 +513,25 @@ async def change_password( # Single transaction: a separate refresh-token purge could fail after the # password commit, leaving pre-change tokens able to mint access tokens. - storage.update_password(current_subject, payload.new_password, revoke_refresh_tokens = True) + # Conditional on the hash just verified: a reset-password that landed while + # this request was in flight must not be overwritten by it. + new_secret = storage.update_password( + current_subject, + payload.new_password, + revoke_refresh_tokens = True, + expect_password_hash = pwd_hash, + ) + if new_secret is None: + raise HTTPException( + status_code = status.HTTP_409_CONFLICT, + detail = "The password changed while this request was in flight. Sign in again.", + ) try: request.app.state.bootstrap_password = None except AttributeError: pass - access_token = create_access_token(subject = current_subject) - refresh_token = create_refresh_token(subject = current_subject) + access_token = create_access_token(subject = current_subject, secret = new_secret) + refresh_token = create_refresh_token(subject = current_subject, secret = new_secret) return Token( access_token = access_token, refresh_token = refresh_token, @@ -541,20 +559,28 @@ def _row_to_api_key_response(row: dict) -> ApiKeyResponse: @router.post("/api-keys", response_model = CreateApiKeyResponse) async def create_api_key( - payload: CreateApiKeyRequest, current_subject: str = Depends(get_current_subject) + payload: CreateApiKeyRequest, credential: tuple = Depends(get_current_credential) ) -> CreateApiKeyResponse: """Create a new API key. The raw key is returned once and cannot be retrieved later.""" + current_subject, generation = credential expires_at = None if payload.expires_in_days is not None: expires_at = ( datetime.now(timezone.utc) + timedelta(days = payload.expires_in_days) ).isoformat() - raw_key, row = storage.create_api_key( - username = current_subject, - name = payload.name, - expires_at = expires_at, - ) + try: + raw_key, row = storage.create_api_key( + username = current_subject, + name = payload.name, + expires_at = expires_at, + expect_gen = generation, + ) + except storage.CredentialRotated: + raise HTTPException( + status_code = status.HTTP_401_UNAUTHORIZED, + detail = "Invalid or expired token", + ) return CreateApiKeyResponse( key = raw_key, api_key = _row_to_api_key_response(row), diff --git a/studio/backend/routes/data_recipe/jobs.py b/studio/backend/routes/data_recipe/jobs.py index e870e8855e..7fdf0abada 100644 --- a/studio/backend/routes/data_recipe/jobs.py +++ b/studio/backend/routes/data_recipe/jobs.py @@ -10,7 +10,10 @@ from datetime import datetime, timedelta, timezone from typing import Any, Optional from urllib.parse import urlparse -from fastapi import APIRouter, HTTPException, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from auth.authentication import get_current_credential +from auth.storage import CredentialRotated from fastapi.responses import JSONResponse, StreamingResponse from pydantic import ValidationError @@ -257,7 +260,11 @@ def _inject_local_structured_response_format( model_configs.extend(new_configs) -def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optional[int]: +def _inject_local_providers( + recipe: dict[str, Any], + request: Request, + expect_gen: Optional[str] = None, +) -> Optional[int]: """Mutate recipe in-place: point is_local providers at this server and mint a short-lived internal sk-unsloth-* key for workflow auth. @@ -313,6 +320,7 @@ def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optiona name = "data-recipe workflow", expires_at = expires_at, internal = True, + expect_gen = expect_gen, ) internal_key_id = int(row["id"]) @@ -375,7 +383,11 @@ def _normalize_run_name(value: Any) -> str | None: @router.post("/jobs", response_class = JSONResponse, response_model = JobCreateResponse) -def create_job(payload: RecipePayload, request: Request): +def create_job( + payload: RecipePayload, + request: Request, + credential: tuple = Depends(get_current_credential), +): recipe = payload.recipe if not recipe.get("columns"): raise HTTPException(status_code = 400, detail = "Recipe must include columns.") @@ -406,7 +418,11 @@ def create_job(payload: RecipePayload, request: Request): ) from exc try: - internal_api_key_id = _inject_local_providers(recipe, request) + internal_api_key_id = _inject_local_providers(recipe, request, credential[1]) + except CredentialRotated as exc: + # A reset-password landed after this request authenticated; the workflow key + # is refused, so answer like any other revoked credential rather than 500. + raise HTTPException(status_code = 401, detail = "Invalid or expired token") from exc except ValueError as exc: raise log_and_http_error( exc, diff --git a/studio/backend/run.py b/studio/backend/run.py index ef372e004e..076a1f851b 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -1328,7 +1328,8 @@ def _apply_supplied_password(password_value: "Optional[str]") -> None: if not _auth_storage.requires_password_change(_admin): print( "Error: an Unsloth admin password is already set; --password only sets " - "the initial password. Run `unsloth studio reset-password` first.", + "the initial password. Change it in the UI, or run `unsloth studio " + "reset-password` for a new one.", file = sys.stderr, flush = True, ) diff --git a/studio/backend/tests/test_change_password_policy.py b/studio/backend/tests/test_change_password_policy.py index c73e9ed839..fc095760d0 100644 --- a/studio/backend/tests/test_change_password_policy.py +++ b/studio/backend/tests/test_change_password_policy.py @@ -67,9 +67,11 @@ def test_rejects_password_containing_spaces(_user): def test_allows_password_without_spaces(_user, monkeypatch): - monkeypatch.setattr(auth_routes.storage, "update_password", lambda *args, **kwargs: True) - monkeypatch.setattr(auth_routes, "create_access_token", lambda subject: "at") - monkeypatch.setattr(auth_routes, "create_refresh_token", lambda subject: "rt") + monkeypatch.setattr( + auth_routes.storage, "update_password", lambda *args, **kwargs: "rotated-secret" + ) + monkeypatch.setattr(auth_routes, "create_access_token", lambda subject, **kwargs: "at") + monkeypatch.setattr(auth_routes, "create_refresh_token", lambda subject, **kwargs: "rt") token = _change("correct-horse-battery") assert token.access_token == "at" assert token.must_change_password is False diff --git a/studio/backend/tests/test_credential_rotation_race.py b/studio/backend/tests/test_credential_rotation_race.py new file mode 100644 index 0000000000..9b0f95aa02 --- /dev/null +++ b/studio/backend/tests/test_credential_rotation_race.py @@ -0,0 +1,255 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""A password rotation must not leave a session minted from the replaced credential. + +`unsloth studio reset-password` rotates in place against a live server, so a login +can verify the old password, have the rotation land, and only then mint its tokens. +Issuance is bound to the credential version that was verified, so such a login gets +tokens that are already dead rather than a session that outlives the reset. +""" + +import secrets +from datetime import datetime, timedelta, timezone + +import jwt +import pytest + +from auth import hashing, storage +from auth.authentication import ALGORITHM, create_access_token, create_refresh_token + + +@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) + yield + + +@pytest.fixture +def admin(): + storage.create_initial_user( + username = storage.DEFAULT_ADMIN_USERNAME, + password = "old-password-123", + jwt_secret = secrets.token_urlsafe(64), + ) + return storage.DEFAULT_ADMIN_USERNAME + + +def _verified_secret(username): + return storage.get_user_and_secret(username)[2] + + +def test_access_token_from_the_replaced_credential_is_rejected(admin): + secret = _verified_secret(admin) + + storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True) + token = create_access_token(subject = admin, secret = secret) + + with pytest.raises(jwt.InvalidTokenError): + jwt.decode(token, storage.get_jwt_secret(admin), algorithms = [ALGORITHM]) + + +def test_refresh_token_from_the_replaced_credential_is_rejected(admin): + secret = _verified_secret(admin) + + # Inserted AFTER the rotation's DELETE, so revocation alone cannot catch it. + storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True) + token = create_refresh_token(subject = admin, secret = secret) + + assert storage.verify_refresh_token(token) is None + assert storage.consume_refresh_token(token) is None + + +def test_a_rejected_refresh_token_is_dropped(admin): + secret = _verified_secret(admin) + storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True) + token = create_refresh_token(subject = admin, secret = secret) + + storage.verify_refresh_token(token) + + conn = storage.get_connection() + try: + assert conn.execute("SELECT COUNT(*) AS c FROM refresh_tokens").fetchone()["c"] == 0 + finally: + conn.close() + + +def test_tokens_from_the_current_credential_still_work(admin): + secret = _verified_secret(admin) + + access = create_access_token(subject = admin, secret = secret) + refresh = create_refresh_token(subject = admin, secret = secret) + + jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM]) + assert storage.verify_refresh_token(refresh) == (admin, False) + + +def test_refresh_cannot_outlive_a_rotation_it_raced(admin): + # /refresh consumes, then mints. A rotation landing in between must not let + # the replacement pair be signed with the credential that just replaced it. + secret = _verified_secret(admin) + token = create_refresh_token(subject = admin, secret = secret) + consumed = storage.consume_refresh_token(token) + assert consumed is not None + _username, _is_desktop, consumed_secret = consumed + + storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True) + access = create_access_token(subject = admin, secret = consumed_secret) + refresh = create_refresh_token(subject = admin, secret = consumed_secret) + + with pytest.raises(jwt.InvalidTokenError): + jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM]) + assert storage.verify_refresh_token(refresh) is None + + +def test_desktop_login_cannot_outlive_a_rotation_it_raced(admin): + # The reset deletes the desktop secret, so a desktop-login that validated it + # just beforehand must not mint a session that survives. + raw = storage.create_desktop_secret() + verified = storage.validate_desktop_secret_with_credential(raw) + assert verified is not None + _username, verified_secret = verified + + storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True) + access = create_access_token(subject = admin, desktop = True, secret = verified_secret) + refresh = create_refresh_token(subject = admin, desktop = True, secret = verified_secret) + + with pytest.raises(jwt.InvalidTokenError): + jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM]) + assert storage.verify_refresh_token(refresh) is None + + +def test_change_password_cannot_overwrite_a_rotation_it_raced(admin): + # A change-password that verified the old hash must not clobber a reset that + # committed while it was in flight. + _salt, verified_hash, _secret, _must_change = storage.get_user_and_secret(admin) + + storage.update_password(admin, "reset-by-the-cli-789", revoke_refresh_tokens = True) + + assert not storage.update_password( + admin, + "attacker-chosen-000", + revoke_refresh_tokens = True, + expect_password_hash = verified_hash, + ) + salt, pwd_hash, _s, _m = storage.get_user_and_secret(admin) + assert hashing.verify_password("reset-by-the-cli-789", salt, pwd_hash) + + +def test_api_key_creation_from_a_revoked_credential_is_refused(admin): + generation = storage.credential_generation(_verified_secret(admin)) + + storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True) + + with pytest.raises(storage.CredentialRotated): + storage.create_api_key(username = admin, name = "k", expect_gen = generation) + conn = storage.get_connection() + try: + assert conn.execute("SELECT COUNT(*) AS c FROM api_keys").fetchone()["c"] == 0 + finally: + conn.close() + + +def test_api_key_creation_under_the_current_credential_still_works(admin): + generation = storage.credential_generation(_verified_secret(admin)) + + raw_key, _row = storage.create_api_key(username = admin, name = "k", expect_gen = generation) + + assert storage.validate_api_key(raw_key) == admin + + +def test_change_password_tokens_are_bound_to_its_own_write(admin): + # The tokens returned to a successful change-password must be signed with the + # secret that write produced, not whatever a later reset put in the DB. + _salt, verified_hash, _secret, _must = storage.get_user_and_secret(admin) + new_secret = storage.update_password( + admin, + "chosen-by-the-user", + revoke_refresh_tokens = True, + expect_password_hash = verified_hash, + ) + assert new_secret is not None + + storage.update_password(admin, "reset-by-the-cli-789", revoke_refresh_tokens = True) + access = create_access_token(subject = admin, secret = new_secret) + refresh = create_refresh_token(subject = admin, secret = new_secret) + + with pytest.raises(jwt.InvalidTokenError): + jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM]) + assert storage.verify_refresh_token(refresh) is None + + +def test_internal_api_key_minting_honours_the_request_generation(admin): + generation = storage.credential_generation(_verified_secret(admin)) + storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True) + + with pytest.raises(storage.CredentialRotated): + storage.create_api_key( + username = admin, + name = "data-recipe workflow", + internal = True, + expect_gen = generation, + ) + + +def test_api_key_auth_reports_the_version_the_key_was_valid_under(admin): + # The generation must come from the same transaction as the key check, or a + # revoked key could hand a route the post-reset generation and mint again. + raw, _row = storage.create_api_key(username = admin, name = "agent") + verified = storage.validate_api_key_with_credential(raw) + assert verified is not None + _user, secret = verified + generation = storage.credential_generation(secret) + + storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True) + conn = storage.get_connection() + try: + conn.execute("DELETE FROM api_keys") + conn.commit() + finally: + conn.close() + + assert storage.validate_api_key(raw) is None + with pytest.raises(storage.CredentialRotated): + storage.create_api_key(username = admin, name = "after", expect_gen = generation) + + +def test_consuming_a_legacy_token_reports_the_pre_reset_credential(admin): + # An unstamped row has no generation to compare, so consume must read the + # credential inside the delete transaction rather than after committing it. + token = secrets.token_urlsafe(48) + expires_at = (datetime.now(timezone.utc) + timedelta(days = 7)).isoformat() + storage.save_refresh_token(token, admin, expires_at, secret_gen = None) + conn = storage.get_connection() + try: + conn.execute("UPDATE refresh_tokens SET secret_gen = NULL") + conn.commit() + finally: + conn.close() + + consumed = storage.consume_refresh_token(token) + assert consumed is not None + _username, _is_desktop, consumed_secret = consumed + + storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True) + access = create_access_token(subject = admin, secret = consumed_secret) + with pytest.raises(jwt.InvalidTokenError): + jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM]) + + +def test_unstamped_legacy_tokens_still_verify(admin): + # Rows written before the secret_gen column existed must not log users out. + token = secrets.token_urlsafe(48) + expires_at = (datetime.now(timezone.utc) + timedelta(days = 7)).isoformat() + storage.save_refresh_token(token, admin, expires_at, secret_gen = None) + conn = storage.get_connection() + try: + conn.execute("UPDATE refresh_tokens SET secret_gen = NULL") + conn.commit() + finally: + conn.close() + + assert storage.verify_refresh_token(token) == (admin, False) diff --git a/studio/backend/tests/test_desktop_auth.py b/studio/backend/tests/test_desktop_auth.py index cbffe9568d..039bb5e3e6 100644 --- a/studio/backend/tests/test_desktop_auth.py +++ b/studio/backend/tests/test_desktop_auth.py @@ -445,7 +445,7 @@ def test_consume_refresh_token_second_call_returns_none(): storage.save_refresh_token(raw, storage.DEFAULT_ADMIN_USERNAME, expires) first = storage.consume_refresh_token(raw) - assert first == (storage.DEFAULT_ADMIN_USERNAME, False) + assert first[:2] == (storage.DEFAULT_ADMIN_USERNAME, False) second = storage.consume_refresh_token(raw) assert second is None @@ -474,7 +474,7 @@ def test_consume_refresh_token_concurrent_only_one_succeeds(tmp_path, monkeypatc successes = [r for r in results if r is not None] assert len(successes) == 1, f"expected exactly one consumer to win, got {len(successes)}" - assert successes[0] == (storage.DEFAULT_ADMIN_USERNAME, False) + assert successes[0][:2] == (storage.DEFAULT_ADMIN_USERNAME, False) def test_consume_refresh_token_expired_returns_none(): @@ -548,6 +548,28 @@ def test_local_recipe_token_authenticates_as_admin_for_web_user(loaded_local_mod assert asyncio.run(get_current_subject(credentials)) == storage.DEFAULT_ADMIN_USERNAME +def test_rotated_credential_job_start_is_401_not_500(loaded_local_model): + # A reset-password landing mid-request makes the workflow-key mint refuse. + # That must reach the client as a revoked credential, not an unhandled error. + from fastapi import HTTPException + + seed_user() + jobs_route = data_recipe_jobs_module() + stale_gen = storage.credential_generation(secrets.token_urlsafe(64)) + + with pytest.raises(storage.CredentialRotated): + jobs_route._inject_local_providers(local_recipe(), local_recipe_request("t"), stale_gen) + + def _boom(*_a, **_k): + raise storage.CredentialRotated("revoked") + + jobs_route._inject_local_providers = _boom + payload = SimpleNamespace(recipe = local_recipe(), run = {}) + with pytest.raises(HTTPException) as excinfo: + jobs_route.create_job(payload, local_recipe_request("t"), ("unsloth", stale_gen)) + assert excinfo.value.status_code == 401 + + def test_desktop_login_rejects_invalid_secret(): seed_user(must_change_password = False) client = auth_client() @@ -580,18 +602,31 @@ def test_reset_password_removes_desktop_secret_files(tmp_path, monkeypatch): from unsloth_cli.commands import studio as studio_cli auth_dir = tmp_path / "auth" - auth_dir.mkdir() - (auth_dir / "auth.db").write_text("db") - (auth_dir / ".bootstrap_password").write_text("boot") - (auth_dir / ".desktop_secret").write_text("new") monkeypatch.setattr(studio_cli, "STUDIO_HOME", tmp_path) + secret = studio_cli._create_desktop_secret_in_cli() + studio_cli._write_auth_secret(auth_dir / studio_cli.DESKTOP_SECRET_FILE, secret) + (auth_dir / studio_cli.BOOTSTRAP_PASSWORD_FILE).write_text("boot") result = CliRunner().invoke(studio_cli.studio_app, ["reset-password"]) - assert result.exit_code == 0 - assert not (auth_dir / "auth.db").exists() - assert not (auth_dir / ".bootstrap_password").exists() - assert not (auth_dir / ".desktop_secret").exists() + assert result.exit_code == 0, result.output + # The DB survives on purpose: a running server keeps serving from its admin row. + assert (auth_dir / "auth.db").exists() + assert not (auth_dir / studio_cli.BOOTSTRAP_PASSWORD_FILE).exists() + assert not (auth_dir / studio_cli.DESKTOP_SECRET_FILE).exists() + + conn = studio_cli._connect_auth_db() + try: + surviving = conn.execute( + "SELECT COUNT(*) FROM app_secrets WHERE key IN (?, ?)", + ( + studio_cli.DESKTOP_SECRET_HASH_KEY, + studio_cli.DESKTOP_SECRET_CREATED_AT_KEY, + ), + ).fetchone()[0] + finally: + conn.close() + assert surviving == 0 def test_reset_password_removes_desktop_secret_files_without_db(tmp_path, monkeypatch): @@ -846,7 +881,7 @@ def test_update_password_clears_desktop_secret(): assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME changed = storage.update_password(storage.DEFAULT_ADMIN_USERNAME, "new-admin-password") - assert changed is True + assert changed assert storage.validate_desktop_secret(raw) is None @@ -855,7 +890,7 @@ def test_update_password_on_unknown_user_leaves_desktop_secret_intact(): raw = storage.create_desktop_secret() changed = storage.update_password("not-a-user", "irrelevant") - assert changed is False + assert not changed assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME diff --git a/studio/backend/tests/test_password_prompt_backstop.py b/studio/backend/tests/test_password_prompt_backstop.py index 3c2c1956f9..6c22532532 100644 --- a/studio/backend/tests/test_password_prompt_backstop.py +++ b/studio/backend/tests/test_password_prompt_backstop.py @@ -247,8 +247,8 @@ def test_lifespan_honors_bootstrap_suppression_in_source(): def test_clear_bootstrap_password_truncates_when_unlink_fails(monkeypatch, tmp_path): # If the file cannot be unlinked (Windows AV / read-only auth dir), clear must # truncate it so its stale plaintext cannot be re-seeded by - # generate_bootstrap_password() after a later reset-password deletes auth.db, - # which would re-validate the revoked bootstrap password. + # generate_bootstrap_password() if auth.db is ever recreated, which would + # re-validate the revoked bootstrap password. import pathlib pw_path = tmp_path / ".bootstrap_password" diff --git a/unsloth_cli/commands/studio.py b/unsloth_cli/commands/studio.py index bfd748ae00..68a5a6357d 100644 --- a/unsloth_cli/commands/studio.py +++ b/unsloth_cli/commands/studio.py @@ -505,6 +505,8 @@ def _connect_auth_db() -> sqlite3.Connection: auth_dir = STUDIO_HOME / "auth" auth_dir.mkdir(parents = True, exist_ok = True) conn = sqlite3.connect(auth_dir / "auth.db") + # A live server writes this DB while the CLI runs; the default lock wait is zero. + conn.execute("PRAGMA busy_timeout=5000") # Mirror backend storage.get_connection: this path can create auth/ and # auth.db (the pre-exposure gate writes here first), and sqlite3.connect # makes the DB 0644 under a 022 umask. Keep both private. @@ -532,7 +534,8 @@ def _connect_auth_db() -> sqlite3.Connection: token_hash TEXT NOT NULL, username TEXT NOT NULL, expires_at TEXT NOT NULL, - is_desktop INTEGER NOT NULL DEFAULT 0 + is_desktop INTEGER NOT NULL DEFAULT 0, + secret_gen TEXT ); """ ) @@ -567,6 +570,8 @@ def _connect_auth_db() -> sqlite3.Connection: refresh_columns = {row[1] for row in conn.execute("PRAGMA table_info(refresh_tokens)")} if "is_desktop" not in refresh_columns: conn.execute("ALTER TABLE refresh_tokens ADD COLUMN is_desktop INTEGER NOT NULL DEFAULT 0") + if "secret_gen" not in refresh_columns: + conn.execute("ALTER TABLE refresh_tokens ADD COLUMN secret_gen TEXT") conn.commit() return conn @@ -700,12 +705,30 @@ def _bootstrap_deadline_active() -> bool: return True -def _cli_update_password(conn: sqlite3.Connection, username: str, new_password: str) -> None: +def _generate_reset_password() -> str: + """Readable 4-word passphrase; the user has to type this one back in.""" + try: + import diceware + return diceware.get_passphrase( + options = diceware.handle_options(args = ["-n", "4", "-d", "", "-c"]) + ) + except Exception: + return secrets.token_urlsafe(24) + + +def _cli_update_password( + conn: sqlite3.Connection, + username: str, + new_password: str, + *, + revoke_api_keys: bool = False, +) -> None: """CLI mirror of backend update_password + change-password route effects. One transaction: rehash, rotate the JWT secret, clear must_change_password, - revoke refresh tokens (PR #6651 finding), and drop the desktop secret. File - cleanup happens after commit; a failed unlink must not roll the change back. + revoke refresh tokens (PR #6651 finding), drop the desktop secret, and (for a + reset) the API keys the old credential could have minted. File cleanup happens + after commit; a failed unlink must not roll the change back. """ password_salt, password_hash = _hash_password(new_password) with conn: @@ -722,6 +745,8 @@ def _cli_update_password(conn: sqlite3.Connection, username: str, new_password: "DELETE FROM app_secrets WHERE key IN (?, ?)", (DESKTOP_SECRET_HASH_KEY, DESKTOP_SECRET_CREATED_AT_KEY), ) + if revoke_api_keys: + conn.execute("DELETE FROM api_keys") for stale in (BOOTSTRAP_PASSWORD_FILE, DESKTOP_SECRET_FILE): stale_path = STUDIO_HOME / "auth" / stale try: @@ -731,8 +756,8 @@ def _cli_update_password(conn: sqlite3.Connection, username: str, new_password: # change back. But a locked-yet-writable file (Windows AV, read-only # auth dir) must be truncated: otherwise its stale plaintext survives # and generate_bootstrap_password() would re-validate this revoked - # credential after a later reset-password deletes auth.db. Mirrors - # backend clear_bootstrap_password(). + # credential if auth.db is ever recreated. Mirrors backend + # clear_bootstrap_password(). try: stale_path.write_text("", encoding = "utf-8") cleared = True @@ -790,8 +815,8 @@ def _apply_supplied_password_before_launch(supplied_password: "str | None") -> N if not row[2]: typer.echo( "Error: an Unsloth admin password is already set; --password only sets " - "the initial password. Run `unsloth studio reset-password` first " - "(or change it in the UI).", + "the initial password. Change it in the UI, or run `unsloth studio " + "reset-password` for a new one.", err = True, ) raise typer.Exit(1) @@ -2893,59 +2918,33 @@ def provision_desktop_auth(): def reset_password(): """Reset the Unsloth admin password. - Deletes the auth database so that a fresh admin account with a new - random password is created on the next server start. The Unsloth - server must be restarted after running this command. + Rotates the credential in place: a running Unsloth accepts the new password on + its next request, so there is nothing to restart. Shared /p preview links are + not revoked -- rotate those in Settings if the old password leaked. """ - auth_dir = STUDIO_HOME / "auth" - db_file = auth_dir / "auth.db" - stale_files = [ - auth_dir / BOOTSTRAP_PASSWORD_FILE, - auth_dir / DESKTOP_SECRET_FILE, - ] - had_db = db_file.exists() - - # Delete auth.db FIRST and prove it is gone before touching the seeded - # credential files. If it cannot be removed (a running Unsloth or Windows - # holds it open, or a read-only auth dir), abort with the credential files - # untouched: deleting them while an un-resettable DB (must_change_password=1) - # survives would lock a forgotten-password reset out of any recovery - # credential. Failing here leaves a consistent, still-recoverable state. + new_password = _generate_reset_password() try: - db_file.unlink(missing_ok = True) - except OSError as exc: + conn = _connect_auth_db() + except (OSError, sqlite3.Error) as exc: typer.echo( - f"Error: could not delete the auth database ({exc}). Stop any running " - "Unsloth and retry; no credential files were changed.", + f"Error: could not open the auth database ({exc}). Check that " + f"{STUDIO_HOME / 'auth'} is writable; if auth.db itself is unreadable, stop " + "Unsloth, delete it, and start again to re-seed.", err = True, ) raise typer.Exit(1) - # The DB is gone, so the next start re-seeds. Invalidate the seeded plaintext - # credential files so that re-seed generates a FRESH password instead of - # reusing a stale one: unlink only ignores FileNotFoundError, so a - # locked/undeletable file (Windows AV, read-only dir) would otherwise survive - # and generate_bootstrap_password() would read it back and re-validate the - # credential this reset revoked. Truncate on unlink failure; if a file can be - # neither removed nor truncated, fail closed -- the DB is already gone, so a - # surviving plaintext would be reused, and the user must remove it manually. - for path in stale_files: - try: - path.unlink(missing_ok = True) - except OSError: - try: - path.write_text("", encoding = "utf-8") - except OSError as exc: - typer.echo( - f"Error: could not remove or clear {path.name} ({exc}); delete " - "it manually before restarting Unsloth or the old password may " - "be reused.", - err = True, - ) - raise typer.Exit(1) + try: + _ensure_cli_default_admin(conn) + _cli_update_password(conn, DEFAULT_ADMIN_USERNAME, new_password, revoke_api_keys = True) + except (OSError, sqlite3.Error) as exc: + typer.echo(f"Error: could not reset the password ({exc}).", err = True) + raise typer.Exit(1) + finally: + conn.close() - if not had_db: - typer.echo("No auth database found -- nothing to reset.") - raise typer.Exit(0) - - typer.echo("Auth database deleted. Restart Unsloth Studio to get a new password.") + typer.echo(f"New password for '{DEFAULT_ADMIN_USERNAME}': {new_password}") + typer.echo( + "Sessions and API keys revoked. A running Unsloth takes it on the next request, " + "though repeated failed logins can hold the rate limit shut for up to a minute." + ) diff --git a/unsloth_cli/tests/test_studio_password_prompt.py b/unsloth_cli/tests/test_studio_password_prompt.py index 48437b0655..753c22edc2 100644 --- a/unsloth_cli/tests/test_studio_password_prompt.py +++ b/unsloth_cli/tests/test_studio_password_prompt.py @@ -963,7 +963,9 @@ def test_run_reexec_forwards_resolved_frontend_on_public_launch(monkeypatch, tmp exec_argv = [argv for kind, argv in events if kind == "exec"][0] assert "--frontend" in exec_argv, exec_argv - assert exec_argv[exec_argv.index("--frontend") + 1] == "/fake/studio/frontend/dist", exec_argv + # str(Path(...)), not the literal: Windows renders it with backslashes. + expected_dist = str(Path("/fake/studio/frontend/dist")) + assert exec_argv[exec_argv.index("--frontend") + 1] == expected_dist, exec_argv def test_run_non_tty_persists_seeded_admin_on_fresh_home(monkeypatch, tmp_path): @@ -1038,50 +1040,173 @@ def test_bootstrap_deadline_active_mirrors_backend_parsing(monkeypatch, raw, exp assert studio_mod._bootstrap_deadline_active() is expected -def test_reset_password_truncates_locked_bootstrap_after_db_delete(monkeypatch, tmp_path): - # reset-password deletes auth.db first, then invalidates the seeded credential - # files. A locked/undeletable .bootstrap_password must be truncated so its - # stale plaintext cannot be re-seeded (generate_bootstrap_password reuses a - # non-empty file), while the reset still succeeds. - import pathlib - - studio_mod = _studio() - monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) - _seed_auth(studio_mod) - auth_dir = tmp_path / "auth" - bootstrap_file = auth_dir / studio_mod.BOOTSTRAP_PASSWORD_FILE - db_file = auth_dir / "auth.db" - assert bootstrap_file.exists() and db_file.exists() - assert bootstrap_file.read_text().strip() - - _real_unlink = pathlib.Path.unlink - - def _boom_unlink(self, *a, **k): - if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE: - raise OSError("locked") - return _real_unlink(self, *a, **k) - - monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink) - +def _reset_password_cli(studio_mod): import typer as _typer app = _typer.Typer() app.command()(studio_mod.reset_password) - result = CliRunner().invoke(app, [], catch_exceptions = True) + return CliRunner().invoke(app, [], catch_exceptions = True) + + +def _password_works(studio_mod, candidate): + conn = studio_mod._connect_auth_db() + try: + row = conn.execute( + "SELECT password_salt, password_hash FROM auth_user WHERE username = ?", + (studio_mod.DEFAULT_ADMIN_USERNAME,), + ).fetchone() + finally: + conn.close() + return studio_mod._pbkdf2_hex(candidate, row[0].encode("utf-8")) == row[1] + + +def _printed_password(result): + line = next(l for l in result.output.splitlines() if l.startswith("New password for")) + return line.split(": ", 1)[1].strip() + + +def test_reset_password_rotates_in_place_without_deleting_the_db(monkeypatch, tmp_path): + # The DB survives, so a running server keeps its admin row and the new password. + studio_mod = _studio() + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + _seed_auth(studio_mod) + db_file = tmp_path / "auth" / "auth.db" + before = _auth_state(studio_mod) + + result = _reset_password_cli(studio_mod) assert result.exit_code == 0, result.output - assert not db_file.exists() - # The locked file survives, but truncated -- no reusable plaintext. - assert bootstrap_file.exists() - assert bootstrap_file.read_text() == "" + assert db_file.exists() + after = _auth_state(studio_mod) + assert after["password_hash"] != before["password_hash"] + assert after["jwt_secret"] != before["jwt_secret"] + assert _password_works(studio_mod, _printed_password(result)) + + +def test_reset_password_waits_out_a_concurrent_writer(monkeypatch, tmp_path): + # The CLI now writes while the server does; without a busy_timeout this fails. + import threading + import time + + studio_mod = _studio() + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + _seed_auth(studio_mod) + released = threading.Event() + + def hold_write_lock(): + conn = sqlite3.connect(_auth_db(tmp_path)) + conn.execute("BEGIN IMMEDIATE") + conn.execute( + "INSERT INTO refresh_tokens (token_hash, username, expires_at) " + "VALUES ('held', 'unsloth', '2099-01-01T00:00:00')" + ) + time.sleep(0.5) + conn.rollback() + conn.close() + released.set() + + holder = threading.Thread(target = hold_write_lock) + holder.start() + time.sleep(0.1) + result = _reset_password_cli(studio_mod) + holder.join() + + assert released.is_set() + assert result.exit_code == 0, result.output + assert _password_works(studio_mod, _printed_password(result)) + + +def test_reset_password_revokes_sessions_and_api_keys(monkeypatch, tmp_path): + # Deleting auth.db used to drop these implicitly. + studio_mod = _studio() + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + _seed_auth(studio_mod) + conn = studio_mod._connect_auth_db() + conn.execute( + "INSERT INTO api_keys (username, key_prefix, key_hash, name, created_at) " + "VALUES (?, 'sk-x', 'hash', 'k', '2026-01-01T00:00:00')", + (studio_mod.DEFAULT_ADMIN_USERNAME,), + ) + conn.commit() + conn.close() + + assert _reset_password_cli(studio_mod).exit_code == 0 + + conn = studio_mod._connect_auth_db() + try: + assert conn.execute("SELECT COUNT(*) FROM api_keys").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM refresh_tokens").fetchone()[0] == 0 + finally: + conn.close() + + +def test_reset_password_leaves_the_account_ready_to_log_in(monkeypatch, tmp_path): + # must_change_password stays 0 on purpose: at 1 a running server injects its + # startup-cached (now wrong) bootstrap password into the login page. + studio_mod = _studio() + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + _seed_auth(studio_mod) + + assert _reset_password_cli(studio_mod).exit_code == 0 + + assert _auth_state(studio_mod)["must_change_password"] == 0 + assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists() + + +def test_reset_password_seeds_the_admin_when_no_db_exists(monkeypatch, tmp_path): + studio_mod = _studio() + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + + result = _reset_password_cli(studio_mod) + + assert result.exit_code == 0, result.output + assert _password_works(studio_mod, _printed_password(result)) + + +def test_reset_password_reports_an_unwritable_auth_dir(monkeypatch, tmp_path): + # _connect_auth_db creates auth/ before it opens SQLite, so a read-only Unsloth + # home raises OSError, not sqlite3.Error. + import pathlib + + studio_mod = _studio() + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + + def _boom_mkdir(self, *a, **k): + raise PermissionError("read-only") + + monkeypatch.setattr(pathlib.Path, "mkdir", _boom_mkdir) + + result = _reset_password_cli(studio_mod) + + assert result.exit_code == 1, result.output + assert not isinstance(result.exception, OSError) + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "could not open the auth database" in combined.lower() + + +def test_reset_password_reports_an_unreadable_db(monkeypatch, tmp_path): + # Deleting a corrupt DB here would revive the bug: a running server would be + # left with no admin row, rejecting the correct password until restarted. + studio_mod = _studio() + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + auth_dir = tmp_path / "auth" + auth_dir.mkdir() + (auth_dir / "auth.db").write_text("not a database") + + result = _reset_password_cli(studio_mod) + + assert result.exit_code == 1, result.output + assert (auth_dir / "auth.db").exists() + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "could not open the auth database" in combined.lower() def test_cli_update_password_truncates_locked_bootstrap_after_change(monkeypatch, tmp_path): # After a CLI/interactive password change the seeded .bootstrap_password is # deleted. If it cannot be unlinked but is still writable (locked file / # read-only dir), it must be TRUNCATED so its stale plaintext cannot be - # re-seeded by generate_bootstrap_password() after a later reset-password - # deletes auth.db. The change is already committed, so it must NOT roll back. + # re-seeded by generate_bootstrap_password() if auth.db is ever recreated. The + # change is already committed, so it must NOT roll back. import pathlib studio_mod = _studio() @@ -1109,88 +1234,6 @@ def test_cli_update_password_truncates_locked_bootstrap_after_change(monkeypatch assert bootstrap_file.read_text() == "" -def test_reset_password_fails_closed_when_db_cannot_be_deleted(monkeypatch, tmp_path): - # If auth.db cannot be removed (running Unsloth / Windows lock, read-only dir), - # reset must abort BEFORE touching the credential files -- deleting them while - # an un-resettable must_change_password=1 DB survives would lock a - # forgotten-password reset out with no recovery credential. - import pathlib - - studio_mod = _studio() - monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) - _seed_auth(studio_mod) - auth_dir = tmp_path / "auth" - bootstrap_file = auth_dir / studio_mod.BOOTSTRAP_PASSWORD_FILE - db_file = auth_dir / "auth.db" - assert bootstrap_file.exists() and db_file.exists() - - _real_unlink = pathlib.Path.unlink - - def _boom_unlink(self, *a, **k): - if self.name == "auth.db": - raise OSError("database is locked") - return _real_unlink(self, *a, **k) - - monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink) - - import typer as _typer - - app = _typer.Typer() - app.command()(studio_mod.reset_password) - result = CliRunner().invoke(app, [], catch_exceptions = True) - - assert result.exit_code == 1, result.output - # DB still there; credential files untouched (no lockout, no half-done reset). - assert db_file.exists() - assert bootstrap_file.exists() - assert bootstrap_file.read_text().strip() - combined = (result.output or "") + (getattr(result, "stderr", "") or "") - assert "could not delete the auth database" in combined.lower() - - -def test_reset_password_fails_closed_when_credential_cannot_be_invalidated(monkeypatch, tmp_path): - # If a seeded credential file can be neither unlinked nor truncated, reset must - # fail closed: auth.db is already gone, so a surviving plaintext would be - # re-seeded and re-validate the revoked password. - import pathlib - - studio_mod = _studio() - monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) - _seed_auth(studio_mod) - auth_dir = tmp_path / "auth" - bootstrap_file = auth_dir / studio_mod.BOOTSTRAP_PASSWORD_FILE - db_file = auth_dir / "auth.db" - assert bootstrap_file.exists() and db_file.exists() - - _real_unlink = pathlib.Path.unlink - _real_write_text = pathlib.Path.write_text - - def _boom_unlink(self, *a, **k): - if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE: - raise OSError("locked") - return _real_unlink(self, *a, **k) - - def _boom_write_text(self, *a, **k): - if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE: - raise OSError("read-only") - return _real_write_text(self, *a, **k) - - monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink) - monkeypatch.setattr(pathlib.Path, "write_text", _boom_write_text) - - import typer as _typer - - app = _typer.Typer() - app.command()(studio_mod.reset_password) - result = CliRunner().invoke(app, [], catch_exceptions = True) - - assert result.exit_code == 1, result.output - # auth.db was deleted first; the un-invalidatable file is reported for manual removal. - assert not db_file.exists() - combined = (result.output or "") + (getattr(result, "stderr", "") or "") - assert "delete it manually" in combined.lower() - - def test_connect_auth_db_creates_private_files(monkeypatch, tmp_path): # Fresh install: the CLI gate writes the password hash + JWT secret before # the backend ever runs, so this path must apply the same 0700/0600 modes @@ -1405,30 +1448,3 @@ def test_studio_default_password_applies_on_headless_wildcard_no_tunnel(monkeypa assert after["must_change_password"] == 0 assert after["password_hash"] != before["password_hash"] assert "--password" not in _exec_argv(events) - - -def test_reset_password_then_password_roundtrip(monkeypatch, tmp_path): - # After reset-password wipes the DB, the next start re-seeds a fresh admin - # that again requires a change, so --password can set a new initial password. - import typer - - studio_mod = _studio() - monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) - _seed_auth(studio_mod) - conn = studio_mod._connect_auth_db() - studio_mod._cli_update_password(conn, studio_mod.DEFAULT_ADMIN_USERNAME, "first-password-1") - conn.close() - assert _auth_state(studio_mod)["must_change_password"] == 0 - - # reset-password deletes the auth DB + seeded credential files. - try: - studio_mod.reset_password() - except typer.Exit: - pass - assert not (tmp_path / "auth" / "auth.db").exists() - - # A restart re-seeds (ensure_default_admin, must_change=1); --password sets anew. - events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) - _invoke_studio_default(monkeypatch, events, ["--secure", "--password", "second-password-2"]) - assert [kind for kind, _ in events] == ["exec"], events - assert _auth_state(studio_mod)["must_change_password"] == 0