From 3c73a39877fa97db3dff4e22dc685bcb77fa4041 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:10:59 -0700 Subject: [PATCH] fix(cli): verify every credential store transition before reporting it done A keyring backend can accept a write and keep nothing. That is exactly what `keyring --disable` and PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring select, and it raises nothing to distinguish itself, so `lite login` was handing the credential to a black hole, scrubbing its own copy from token.json, and printing a success message over a login that no longer worked. Reading the value back is the only way to tell that backend apart from a keychain that really stored the secret. The same rule closes the rest of the gaps. A credential the token file will not record is taken back out of the keychain instead of being left live on a machine with no record of it, and is reported rather than raised. The migration stages its scrubbed file before the keychain is handed anything, so a directory that will not accept the rewrite stops the move rather than leaving the secret in two places. Logout no longer reads a key in the file as proof that the keychain is clear, which was never sound across two separate runs, and only draws that conclusion when the `keyring` package is missing outright, where nothing could have reached a keychain at all. --- litellm/litellm_core_utils/cli_keyring.py | 26 +++- litellm/litellm_core_utils/cli_token_utils.py | 104 +++++++++----- litellm/litellm_core_utils/private_json.py | 32 ++++- litellm/proxy/client/cli/commands/auth.py | 63 +++++++-- .../test_cli_token_utils.py | 131 ++++++++++++++++-- .../proxy/client/cli/test_auth_commands.py | 62 ++++++++- 6 files changed, 356 insertions(+), 62 deletions(-) diff --git a/litellm/litellm_core_utils/cli_keyring.py b/litellm/litellm_core_utils/cli_keyring.py index 0497991c2a0..15282fc522c 100644 --- a/litellm/litellm_core_utils/cli_keyring.py +++ b/litellm/litellm_core_utils/cli_keyring.py @@ -6,8 +6,12 @@ Linux Secret Service) that holds the credential minted by `lite login`. The `keyring` package is optional and imported lazily, so importing this module never pulls it in. Every failure is returned as a value, naming which of the -three ways the keychain can be out of reach applies, so callers can degrade to -the token file and tell the user what to do about it. +ways the keychain can be out of reach applies, so callers can degrade to the +token file and tell the user what to do about it. + +A write is only reported as stored once it has been read back, because keyring's +null backend, which `keyring --disable` and headless CI images both select, +accepts every write and keeps nothing. """ import os @@ -61,7 +65,12 @@ class KeyringUnreachable: pass -KeyringUnusable: TypeAlias = KeyringNotInstalled | KeyringDisabled | KeyringUnreachable +@dataclass(frozen=True, slots=True) +class KeyringDiscardsWrites: + pass + + +KeyringUnusable: TypeAlias = KeyringNotInstalled | KeyringDisabled | KeyringUnreachable | KeyringDiscardsWrites SecretRead: TypeAlias = SecretFound | SecretMissing | KeyringUnusable SecretWrite: TypeAlias = SecretStored | KeyringUnusable SecretErase: TypeAlias = SecretErased | SecretStranded | KeyringUnusable @@ -119,6 +128,13 @@ class KeyringVault: return SecretMissing() if blob is None else SecretFound(blob) def write(self, blob: str) -> SecretWrite: + """Store the secret, reporting stored only once the keychain hands the same bytes back. + + A backend that accepts writes and keeps nothing, which is exactly what `keyring --disable` + and `PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring` select, raises nothing to + distinguish itself. Reading the value back is the only way to tell it apart from a keychain + that really stored the credential, and the caller is about to drop its own copy on our word. + """ api: Final = _keyring_api() if isinstance(api, (KeyringNotInstalled, KeyringDisabled)): return api @@ -126,7 +142,7 @@ class KeyringVault: api.set_password(KEYRING_SERVICE, KEYRING_ACCOUNT, blob) except Exception: # noqa: BLE001 # a keychain that refuses the write falls back to the token file return KeyringUnreachable() - return SecretStored() + return SecretStored() if self.read() == SecretFound(blob) else KeyringDiscardsWrites() def erase(self) -> SecretErase: """Remove our entry, reporting whether the keychain is guaranteed to be free of it. @@ -137,7 +153,7 @@ class KeyringVault: the caller knows whether this machine ever put a secret in a keychain. """ match self.read(): - case KeyringNotInstalled() | KeyringDisabled() | KeyringUnreachable() as unusable: + case KeyringNotInstalled() | KeyringDisabled() | KeyringUnreachable() | KeyringDiscardsWrites() as unusable: return unusable case SecretMissing(): return SecretErased() diff --git a/litellm/litellm_core_utils/cli_token_utils.py b/litellm/litellm_core_utils/cli_token_utils.py index 53289770c1a..af1b918fc2a 100644 --- a/litellm/litellm_core_utils/cli_token_utils.py +++ b/litellm/litellm_core_utils/cli_token_utils.py @@ -13,15 +13,17 @@ This module has no dependencies on proxy code and can be safely imported at the """ import time +from dataclasses import dataclass from pathlib import Path from types import MappingProxyType -from typing import Final +from typing import Final, TypeAlias from pydantic import BaseModel, ConfigDict, ValidationError from litellm.litellm_core_utils.cli_keyring import ( SYSTEM_KEYRING, KeyringDisabled, + KeyringDiscardsWrites, KeyringNotInstalled, KeyringUnreachable, SecretErase, @@ -33,7 +35,23 @@ from litellm.litellm_core_utils.cli_keyring import ( SecretVault, SecretWrite, ) -from litellm.litellm_core_utils.private_json import ensure_private_dir, write_private_json +from litellm.litellm_core_utils.private_json import ( + commit_staged_json, + discard_staged_json, + ensure_private_dir, + stage_private_json, + write_private_json, +) + + +@dataclass(frozen=True, slots=True) +class CredentialNotSaved: + """The credential was minted but no store would keep it, so this machine has none.""" + + detail: str + + +SecretSave: TypeAlias = SecretWrite | CredentialNotSaved class CliTokenRecord(BaseModel): @@ -85,14 +103,24 @@ def load_cli_token(*, vault: SecretVault = SYSTEM_KEYRING) -> CliTokenRecord | N return _resolve_secret(record, vault) -def save_cli_token(record: CliTokenRecord, *, vault: SecretVault = SYSTEM_KEYRING) -> SecretWrite: - """Store a freshly minted credential. Reports where its secret material ended up, and why""" +def save_cli_token(record: CliTokenRecord, *, vault: SecretVault = SYSTEM_KEYRING) -> SecretSave: + """Store a freshly minted credential. Reports where its secret material ended up, and why. + + The token file is what makes a keychain-backed credential findable again, so a file that will + not be written takes the keychain copy down with it rather than leaving a live credential + stored under a machine that has no record of it. + """ outcome: Final = ( SecretStored() if record.key is None else vault.write(_encode_secret(record.base_url, record.key, record.jwt_token)) ) - _write_token_file(_without_secret(record) if isinstance(outcome, SecretStored) else record) + try: + _write_token_file(_without_secret(record) if isinstance(outcome, SecretStored) else record) + except OSError as error: + if record.key is not None and isinstance(outcome, SecretStored): + vault.erase() + return CredentialNotSaved(str(error)) return outcome @@ -105,24 +133,30 @@ def clear_cli_token(*, vault: SecretVault = SYSTEM_KEYRING) -> SecretErase: def _nothing_left_behind(outcome: SecretErase) -> bool: - """Whether the keychain can be trusted to hold no credential of ours once the file is gone""" + """Whether the keychain can be trusted to hold no credential of ours once the file is gone. + + A keychain that exists but is out of reach right now is never trusted, whatever the token file + looks like: the login that stored a secret there and the logout that cannot remove it are + separate runs, free to differ in whether the keychain was usable at the time. + """ match outcome: case SecretErased(): return True - case SecretStranded(): + case SecretStranded() | KeyringDisabled() | KeyringUnreachable() | KeyringDiscardsWrites(): return False - case KeyringNotInstalled() | KeyringDisabled() | KeyringUnreachable(): - return not _secret_lives_in_keychain() + case KeyringNotInstalled(): + return _file_holds_its_own_secret() -def _secret_lives_in_keychain() -> bool: - """Whether the token file is the metadata half of a pair whose secret half went to a keychain. +def _file_holds_its_own_secret() -> bool: + """Whether the stored login keeps its secret in the token file, ruling out a keychain entry. - A file that still carries its own secret rules one out, which keeps `lite logout` quiet on the - machines that never had a keychain to begin with. + Sound only against a missing `keyring` package, the one way to lose the keychain that had to + hold at storage time too, since nothing here can reach a keychain without it. A file whose + secret half is absent went to a keychain by definition, and so rules nothing out. """ record: Final = _read_token_file() - return record is not None and record.key is None and not record.jwt_token + return record is not None and record.key is not None def get_litellm_gateway_api_key( @@ -179,7 +213,7 @@ def is_cli_token_fresh(token_data: CliTokenRecord, buffer_hours: float = 0.1) -> def _read_token_file() -> CliTokenRecord | None: try: raw: Final = Path(get_cli_token_file_path()).read_text() - except OSError: + except (OSError, ValueError): return None try: return CliTokenRecord.model_validate_json(raw) @@ -193,7 +227,7 @@ def _resolve_secret(record: CliTokenRecord, vault: SecretVault) -> CliTokenRecor return _apply_vault_secret(record, blob, vault) case SecretMissing(): return _migrate_file_secret(record, vault) - case KeyringNotInstalled() | KeyringDisabled() | KeyringUnreachable(): + case KeyringNotInstalled() | KeyringDisabled() | KeyringUnreachable() | KeyringDiscardsWrites(): return record @@ -216,38 +250,46 @@ def _apply_vault_secret(record: CliTokenRecord, blob: str, vault: SecretVault) - def _migrate_file_secret(record: CliTokenRecord, vault: SecretVault) -> CliTokenRecord | None: - """Move a file-held secret into the vault, but only if the file's copy can be taken away. + """Move a file-held secret into the vault, but only once the file's copy can be taken away. - Migrating without scrubbing would leave the credential live in two stores instead of one, so a - file that will not give its copy up rolls the vault write back rather than widening exposure. + The scrubbed file is staged first so a directory that will not accept it stops the migration + before the keychain is handed anything. Copying the credential into a second store and only + then discovering the first one cannot be cleaned would widen exposure instead of narrowing it, + which is the opposite of what moving it into the keychain is for. """ if record.key is None: return None - if not isinstance(vault.write(_encode_secret(record.base_url, record.key, record.jwt_token)), SecretStored): + staged: Final = _stage_scrubbed_file(record) + if staged is None: return record - if not _scrub_file_secret(record): + if not isinstance(vault.write(_encode_secret(record.base_url, record.key, record.jwt_token)), SecretStored): + discard_staged_json(staged) + return record + if not _commit_scrubbed_file(staged): vault.erase() return record def _scrub_file_secret(record: CliTokenRecord) -> bool: - """Leave no secret material in the token file once the vault holds it. - - A file that cannot be rewritten without the secret is removed instead. Signing in again costs - the user one command; a live credential left behind in cleartext costs them the credential. - """ + """Leave no secret material in the token file once the vault holds it""" if record.key is None and not record.jwt_token: return True + staged: Final = _stage_scrubbed_file(record) + return staged is not None and _commit_scrubbed_file(staged) + + +def _stage_scrubbed_file(record: CliTokenRecord) -> str | None: + path: Final = Path(get_cli_token_file_path()) try: - _write_token_file(_without_secret(record)) + ensure_private_dir(path.parent) + return stage_private_json(str(path), _without_secret(record).model_dump(exclude_none=True)) except OSError: - return _discard_token_file() - return True + return None -def _discard_token_file() -> bool: +def _commit_scrubbed_file(staged: str) -> bool: try: - Path(get_cli_token_file_path()).unlink(missing_ok=True) + commit_staged_json(staged, get_cli_token_file_path()) except OSError: return False return True diff --git a/litellm/litellm_core_utils/private_json.py b/litellm/litellm_core_utils/private_json.py index 32bc2e169e2..fbeb74aab5a 100644 --- a/litellm/litellm_core_utils/private_json.py +++ b/litellm/litellm_core_utils/private_json.py @@ -16,8 +16,12 @@ def ensure_private_dir(directory: Path) -> None: directory.chmod(PRIVATE_DIR_MODE) -def write_private_json(path: str, data: Mapping[str, object]) -> None: - """Atomically write JSON to path with owner-only permissions (0600)""" +def stage_private_json(path: str, data: Mapping[str, object]) -> str: + """Write JSON to a private temp file beside `path`, ready for `commit_staged_json`. + + Staging is the half that can fail on a read-only or full directory, so callers with something + to lose can find that out before they act on the assumption that the rewrite will land. + """ parent: Final = Path(path).parent parent.mkdir(parents=True, exist_ok=True) fd, tmp_path = tempfile.mkstemp(dir=str(parent), prefix=".tmp-", suffix=".json") @@ -26,6 +30,26 @@ def write_private_json(path: str, data: Mapping[str, object]) -> None: json.dump(data, f, indent=2) f.flush() os.fsync(f.fileno()) - os.replace(tmp_path, path) - finally: + except BaseException: Path(tmp_path).unlink(missing_ok=True) + raise + return tmp_path + + +def commit_staged_json(staged: str, path: str) -> None: + """Move a staged file into place, replacing whatever is there in one step""" + try: + os.replace(staged, path) + except OSError: + Path(staged).unlink(missing_ok=True) + raise + + +def discard_staged_json(staged: str) -> None: + """Throw a staged file away when the change it was part of is abandoned""" + Path(staged).unlink(missing_ok=True) + + +def write_private_json(path: str, data: Mapping[str, object]) -> None: + """Atomically write JSON to path with owner-only permissions (0600)""" + commit_staged_json(stage_private_json(path, data), path) diff --git a/litellm/proxy/client/cli/commands/auth.py b/litellm/proxy/client/cli/commands/auth.py index 4cf18435473..eba9994f7ec 100644 --- a/litellm/proxy/client/cli/commands/auth.py +++ b/litellm/proxy/client/cli/commands/auth.py @@ -15,16 +15,20 @@ from litellm.litellm_core_utils.cli_keyring import ( DISABLE_KEYRING_ENV_VAR, SYSTEM_KEYRING, KeyringDisabled, + KeyringDiscardsWrites, KeyringNotInstalled, KeyringUnreachable, SecretErased, + SecretFound, + SecretMissing, SecretStored, SecretStranded, SecretVault, - SecretWrite, ) from litellm.litellm_core_utils.cli_token_utils import ( CliTokenRecord, + CredentialNotSaved, + SecretSave, clear_cli_token, get_cli_token_file_path, get_litellm_gateway_api_key, @@ -84,17 +88,19 @@ class CliAuthResult(TypedDict): KEYRING_INSTALL_HINT: Final = "pip install 'litellm[cli]'" +KEYRING_ENABLE_HINT: Final = "keyring --enable (or unset PYTHON_KEYRING_BACKEND)" + STRANDED_CREDENTIAL_MESSAGE: Final = ( "Logged out locally, but your credential is still in the OS keychain and could not be removed." ) -KEYCHAIN_UNREACHABLE_MESSAGE: Final = ( - "Your credential is stored in your OS keychain, which could not be read. Unlock it, or install " - f"the keyring package with: {KEYRING_INSTALL_HINT}. Run 'lite login' to start over." +UNCHECKED_KEYCHAIN_MESSAGE: Final = ( + "Logged out locally, but your OS keychain could not be checked, so a credential stored there by " + "an earlier login may still be usable." ) -def storage_notice(outcome: SecretWrite) -> str: +def storage_notice(outcome: SecretSave) -> str: """Tell the user where the credential ended up, and how to get keychain storage if it did not.""" path: Final = get_cli_token_file_path() match outcome: @@ -109,6 +115,38 @@ def storage_notice(outcome: SecretWrite) -> str: return f"Keychain storage is off ({DISABLE_KEYRING_ENV_VAR}). Credential stored in {path} (owner-only)." case KeyringUnreachable(): return f"No OS keychain available. Credential stored in {path} (owner-only)." + case KeyringDiscardsWrites(): + return ( + f"Your keyring backend keeps nothing it is given, so the credential was stored in {path} " + f"(owner-only) instead. For OS keychain storage, run: {KEYRING_ENABLE_HINT}" + ) + case CredentialNotSaved(detail=detail): + return ( + f"Signed in, but the credential could not be saved to {path}: {detail}. " + "Nothing was kept, so run 'lite login' again once that path is writable." + ) + + +def keychain_unreadable_notice(vault: SecretVault) -> str: + """Explain why the secret half of a stored login cannot be produced, and what fixes it""" + match vault.read(): + case KeyringNotInstalled(): + return ( + "Your credential is in your OS keychain, which this install cannot read without the " + f"keyring package. Install it with: {KEYRING_INSTALL_HINT}, or run 'lite login' to start over." + ) + case KeyringDisabled(): + return ( + f"Your credential is in your OS keychain, which {DISABLE_KEYRING_ENV_VAR} is blocking. " + "Unset it, or run 'lite login' to start over." + ) + case KeyringUnreachable() | KeyringDiscardsWrites(): + return ( + "Your credential is in your OS keychain, which could not be read. Unlock it, or run " + "'lite login' to start over." + ) + case SecretFound() | SecretMissing(): + return "Your credential could not be read from your OS keychain. Run 'lite login' to start over." def context_secret_vault(ctx: click.Context) -> SecretVault: @@ -715,6 +753,8 @@ def login(ctx: click.Context, config_claude: bool): click.echo("\nLogin successful!") click.echo(f"JWT Token: {api_key[:20]}...") click.echo(storage_notice(stored)) + if isinstance(stored, CredentialNotSaved): + return click.echo("You can now use the CLI without specifying --api-key") if config_claude: @@ -751,14 +791,17 @@ def logout(ctx: click.Context): match clear_cli_token(vault=context_secret_vault(ctx)): case SecretErased(): click.echo("Logged out successfully. Authentication token cleared.") + case SecretStranded(): + click.echo(STRANDED_CREDENTIAL_MESSAGE) + click.echo("Unlock your keychain and run 'lite logout' again to clear it.") case KeyringNotInstalled(): click.echo(STRANDED_CREDENTIAL_MESSAGE) click.echo(f"Install the keyring package with: {KEYRING_INSTALL_HINT}, then run 'lite logout' again.") case KeyringDisabled(): - click.echo(STRANDED_CREDENTIAL_MESSAGE) + click.echo(UNCHECKED_KEYCHAIN_MESSAGE) click.echo(f"Unset {DISABLE_KEYRING_ENV_VAR} and run 'lite logout' again to clear it.") - case SecretStranded() | KeyringUnreachable(): - click.echo(STRANDED_CREDENTIAL_MESSAGE) + case KeyringUnreachable() | KeyringDiscardsWrites(): + click.echo(UNCHECKED_KEYCHAIN_MESSAGE) click.echo("Unlock your keychain and run 'lite logout' again to clear it.") @@ -795,7 +838,7 @@ def print_token(ctx: click.Context): api_key: Final = token_data.key if not api_key: - click.echo(KEYCHAIN_UNREACHABLE_MESSAGE, err=True) + click.echo(keychain_unreadable_notice(context_secret_vault(ctx)), err=True) sys.exit(1) click.echo(api_key) @@ -821,7 +864,7 @@ def whoami(ctx: click.Context): click.echo(f"Token age: {age_hours:.1f} hours") if token_data.key is None: - click.echo(KEYCHAIN_UNREACHABLE_MESSAGE) + click.echo(keychain_unreadable_notice(context_secret_vault(ctx))) if age_hours > CLI_JWT_EXPIRATION_HOURS: click.echo(f"Warning: Token is more than {CLI_JWT_EXPIRATION_HOURS} hours old and may have expired.") diff --git a/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py b/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py index cbc93bbde71..e0f5f99dc1b 100644 --- a/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_cli_token_utils.py @@ -15,6 +15,7 @@ from litellm.litellm_core_utils.cli_keyring import ( SecretFound, SecretMissing, KeyringDisabled, + KeyringDiscardsWrites, KeyringNotInstalled, KeyringUnreachable, SecretErased, @@ -23,6 +24,7 @@ from litellm.litellm_core_utils.cli_keyring import ( ) from litellm.litellm_core_utils.cli_token_utils import ( CliTokenRecord, + CredentialNotSaved, clear_cli_token, get_cli_token_file_path, get_litellm_gateway_api_key, @@ -225,6 +227,15 @@ class TestLoadCliToken: assert record.key == "sk-legacy" + def test_a_token_file_that_is_not_text_is_not_a_login(self, isolated_home, secret_vault_factory): + """A truncated write or a half-synced backup can leave bytes that are not UTF-8 at all. + Reading them must fail the way an absent file does, not crash every `lite` command.""" + path = _token_file(isolated_home) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"\xff\xfe not utf-8 at all") + + assert load_cli_token(vault=secret_vault_factory()) is None + def test_corrupt_token_file_is_not_a_login(self, isolated_home, secret_vault_factory): _token_file(isolated_home).parent.mkdir() _token_file(isolated_home).write_text("not json at all {{{") @@ -301,6 +312,40 @@ class TestSaveCliToken: assert stat.S_IMODE(config_dir.stat().st_mode) == 0o700 + def test_a_credential_no_store_would_keep_is_reported_rather_than_raised( + self, isolated_home, secret_vault_factory, monkeypatch + ): + """`lite login` catches whatever escapes here and calls it an authentication failure, which + is the one thing that did not happen: the proxy minted a real credential. Saying so lets the + user act on the actual problem instead of retrying a sign-in that already worked.""" + + def _explode(*args, **kwargs): + raise OSError("read-only file system") + + monkeypatch.setattr("litellm.litellm_core_utils.private_json.json.dump", _explode) + + outcome = save_cli_token(CliTokenRecord(base_url=SERVER, key="sk-new"), vault=secret_vault_factory()) + + assert isinstance(outcome, CredentialNotSaved) + assert "read-only file system" in outcome.detail + + def test_a_credential_the_file_will_not_record_is_taken_back_out_of_the_keychain( + self, isolated_home, secret_vault_factory, monkeypatch + ): + """The token file is what makes a keychain entry findable again. Leaving the secret in the + keychain with nothing pointing at it strands a live credential under a machine that has no + idea it is there, and no `lite logout` would ever go looking for it.""" + vault = secret_vault_factory() + + def _explode(*args, **kwargs): + raise OSError("read-only file system") + + monkeypatch.setattr("litellm.litellm_core_utils.private_json.json.dump", _explode) + + save_cli_token(CliTokenRecord(base_url=SERVER, key="sk-new"), vault=vault) + + assert vault.blob is None + def test_a_failed_write_leaves_the_previous_credential_intact(self, isolated_home, secret_vault_factory, monkeypatch): path = _write_legacy_file(isolated_home) before = path.read_text() @@ -340,9 +385,11 @@ class TestScrubFailure: assert json.loads(path.read_text())["key"] == "sk-legacy" assert vault.blob is None - def test_a_file_that_cannot_be_rewritten_is_removed_instead( + def test_a_full_disk_stops_the_migration_before_the_keychain_is_handed_anything( self, isolated_home, secret_vault_factory, monkeypatch ): + """The scrubbed file is staged first precisely so this is knowable in advance. A disk that + cannot take the rewrite leaves the credential where it already was, in one store.""" path = _write_legacy_file(isolated_home) vault = secret_vault_factory() @@ -354,8 +401,8 @@ class TestScrubFailure: record = load_cli_token(vault=vault) assert record.key == "sk-legacy" - assert json.loads(vault.blob)["key"] == "sk-legacy" - assert not path.exists() + assert vault.blob is None + assert json.loads(path.read_text())["key"] == "sk-legacy" assert list(path.parent.glob(".tmp-*")) == [] @@ -376,12 +423,39 @@ class TestClearCliToken: assert clear_cli_token(vault=vault) == SecretStranded() assert not _token_file(isolated_home).exists() + @pytest.mark.parametrize( + "failure", [KeyringDisabled(), KeyringUnreachable(), KeyringDiscardsWrites()] + ) + def test_a_secret_in_the_file_is_no_evidence_about_a_keychain_that_exists( + self, isolated_home, secret_vault_factory, failure + ): + """Store a secret in the keychain, sign in again while the keychain is unusable so the new + secret lands in the file, then log out while it is still unusable. The file now carries its + own secret and the first login's entry is still there, so reading the file as proof of a + clean keychain reports a logout that did not happen.""" + _write_legacy_file(isolated_home) + vault = secret_vault_factory(available=False, failure=failure) + + assert clear_cli_token(vault=vault) == failure + assert not _token_file(isolated_home).exists() + + def test_a_second_logout_still_reports_the_keychain_it_could_not_clear( + self, isolated_home, secret_vault_factory + ): + """The first logout deletes the file and tells the user to run it again once the keychain is + reachable. If the second run reads that missing file as proof of a clean keychain, the advice + turns into the very false all-clear it was issued to prevent.""" + _write_metadata_only_file(isolated_home) + vault = secret_vault_factory(available=False, failure=KeyringUnreachable()) + + assert clear_cli_token(vault=vault) == KeyringUnreachable() + assert clear_cli_token(vault=vault) == KeyringUnreachable() + def test_logout_from_an_install_without_keyring_does_not_claim_the_keychain_is_clear( self, isolated_home, secret_vault_factory ): - """Log in where `litellm[cli]` is installed and the secret goes to the OS keychain; log out - from a venv without it and the entry survives, because it belongs to the OS rather than to - the package. Reporting a clean logout there leaves a live credential the user thinks is gone.""" + """A file holding only metadata put its secret in a keychain by definition. Losing the + package that reaches it does not take the entry with it, so this cannot report success.""" _write_metadata_only_file(isolated_home) vault = secret_vault_factory(available=False, failure=KeyringNotInstalled()) @@ -389,8 +463,9 @@ class TestClearCliToken: assert not _token_file(isolated_home).exists() def test_a_file_backed_login_logs_out_quietly_without_keyring(self, isolated_home, secret_vault_factory): - """The complement: a user who never had a keychain keeps their whole credential in the file, - so removing it is a complete logout and must not warn about an entry that cannot exist.""" + """The complement, and the one inference the file does support: nothing here can reach a + keychain without the package, so an install that lacks it and a file that still holds its + own secret between them account for the whole credential.""" _write_legacy_file(isolated_home) vault = secret_vault_factory(available=False, failure=KeyringNotInstalled()) @@ -417,11 +492,12 @@ class TestIsCliTokenFresh: class _FakeKeyringModule: - def __init__(self, stored=None, *, get_error=None, set_error=None, delete_error=None): + def __init__(self, stored=None, *, get_error=None, set_error=None, delete_error=None, discard=False): self.stored = stored self.get_error = get_error self.set_error = set_error self.delete_error = delete_error + self.discard = discard self.calls = [] def get_password(self, service_name, username): @@ -434,6 +510,8 @@ class _FakeKeyringModule: self.calls.append(("set", service_name, username)) if self.set_error is not None: raise self.set_error + if self.discard: + return self.stored = password def delete_password(self, service_name, username): @@ -503,6 +581,41 @@ class TestKeyringVault: assert KeyringVault().erase() == SecretStranded() + def test_a_backend_that_keeps_nothing_is_not_a_successful_write(self, install_fake_keyring): + """keyring's null backend accepts every write, stores nothing, and raises nothing to say so. + Taking its silence for success is how a credential gets deleted: the caller drops its own + copy on our word. Only reading the value back tells the two apart.""" + fake = install_fake_keyring(_FakeKeyringModule(discard=True)) + + assert KeyringVault().write("blob-1") == KeyringDiscardsWrites() + assert fake.stored is None + + def test_the_real_null_backend_is_rejected(self, monkeypatch): + """Pinned against the actual library rather than the double above, because the whole risk is + that upstream's no-op write looks exactly like a successful one.""" + keyring = pytest.importorskip("keyring") + null_backend = pytest.importorskip("keyring.backends.null") + monkeypatch.delenv(DISABLE_KEYRING_ENV_VAR, raising=False) + previous = keyring.get_keyring() + keyring.set_keyring(null_backend.Keyring()) + try: + assert KeyringVault().write("blob-1") == KeyringDiscardsWrites() + finally: + keyring.set_keyring(previous) + + def test_a_credential_survives_a_backend_that_keeps_nothing( + self, isolated_home, install_fake_keyring + ): + """The end of the same story: the credential must still be usable afterwards. Reporting the + discard is only worth anything if the token file then keeps the copy the keychain refused.""" + install_fake_keyring(_FakeKeyringModule(discard=True)) + + outcome = save_cli_token(CliTokenRecord(base_url=SERVER, key="sk-only-copy")) + + assert outcome == KeyringDiscardsWrites() + assert json.loads(_token_file(isolated_home).read_text())["key"] == "sk-only-copy" + assert load_cli_token().key == "sk-only-copy" + def test_erasing_a_locked_keychain_is_a_failure(self, install_fake_keyring): install_fake_keyring(_FakeKeyringModule(get_error=RuntimeError("locked"))) diff --git a/tests/test_litellm/proxy/client/cli/test_auth_commands.py b/tests/test_litellm/proxy/client/cli/test_auth_commands.py index 33bd8307c21..8e1551c0720 100644 --- a/tests/test_litellm/proxy/client/cli/test_auth_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_auth_commands.py @@ -16,12 +16,13 @@ from litellm.constants import CLI_JWT_EXPIRATION_HOURS from litellm.litellm_core_utils.cli_keyring import ( DISABLE_KEYRING_ENV_VAR, KeyringDisabled, + KeyringDiscardsWrites, KeyringNotInstalled, ) from litellm.litellm_core_utils.cli_token_utils import CliTokenRecord, save_cli_token from litellm.proxy.client.cli import cli from litellm.proxy.client.cli.commands.auth import ( - KEYCHAIN_UNREACHABLE_MESSAGE, + DISABLE_KEYRING_ENV_VAR, get_stored_api_key, login, logout, @@ -461,6 +462,20 @@ class TestLogoutCommand: assert "still in the OS keychain" in result.output assert "pip install 'litellm[cli]'" in result.output + def test_logout_does_not_call_an_unusable_keychain_clean(self, isolated_home, secret_vault_factory): + """A keychain-backed login, then a login that fell back to the file because the keychain had + become unusable, leaves the first entry live. The file's own secret says nothing about it, + so a clean bill of health here is the one answer that cannot be justified.""" + _write_token_file(isolated_home, key="sk-in-file") + vault = secret_vault_factory(available=False, failure=KeyringDisabled()) + + result = self.runner.invoke(logout, obj={"secret_vault": vault}) + + assert result.exit_code == 0 + assert "Logged out successfully" not in result.output + assert "could not be checked" in result.output + assert DISABLE_KEYRING_ENV_VAR in result.output + def test_logout_warns_when_the_keychain_refuses_to_release_the_entry( self, isolated_home, secret_vault_factory ): @@ -1003,6 +1018,19 @@ class TestKeychainBackedCommands: assert "No OS keychain available" not in result.output assert json.loads(token_file.read_text())["key"] == "sk-minted" + def test_login_keeps_the_credential_when_the_backend_keeps_nothing( + self, isolated_home, secret_vault_factory + ): + """A backend that accepts writes and stores nothing must not be reported as keychain + storage, because the file is then told to drop the only remaining copy.""" + result = self._login(secret_vault_factory(available=False, failure=KeyringDiscardsWrites())) + + token_file = isolated_home / ".litellm" / "token.json" + assert result.exit_code == 0 + assert "Credential stored in your OS keychain." not in result.output + assert "keyring --enable" in result.output + assert json.loads(token_file.read_text())["key"] == "sk-minted" + def test_login_names_the_kill_switch_instead_of_blaming_the_machine( self, isolated_home, secret_vault_factory ): @@ -1061,7 +1089,8 @@ class TestKeychainBackedCommands: result = self.runner.invoke(print_token, obj=obj) assert result.exit_code == 1 - assert KEYCHAIN_UNREACHABLE_MESSAGE in result.output + assert "could not be read" in result.output + assert "lite login" in result.output def test_whoami_flags_a_locked_keychain(self, isolated_home, secret_vault_factory): _write_home_json( @@ -1074,7 +1103,34 @@ class TestKeychainBackedCommands: result = self.runner.invoke(whoami, obj=obj) assert "Authenticated" in result.output - assert KEYCHAIN_UNREACHABLE_MESSAGE in result.output + assert "could not be read" in result.output + + def test_whoami_names_the_kill_switch_rather_than_a_missing_package( + self, isolated_home, secret_vault_factory + ): + """Every unreachable keychain used to be described as a locked one needing the keyring + package installed. Someone who set the kill switch has the package and an unlocked keychain, + so that advice sends them to fix two things that were never wrong.""" + _write_token_file(isolated_home, key=None) + vault = secret_vault_factory(available=False, failure=KeyringDisabled()) + + result = self.runner.invoke(whoami, obj={"base_url": "https://test.example.com", "secret_vault": vault}) + + assert DISABLE_KEYRING_ENV_VAR in result.output + assert "pip install" not in result.output + + def test_print_token_points_an_install_without_keyring_at_the_package( + self, isolated_home, secret_vault_factory + ): + _write_token_file(isolated_home, key=None) + vault = secret_vault_factory(available=False, failure=KeyringNotInstalled()) + obj = {"base_url": "https://test.example.com", "secret_vault": vault} + + result = self.runner.invoke(print_token, obj=obj) + + assert result.exit_code == 1 + assert "pip install 'litellm[cli]'" in result.output + assert DISABLE_KEYRING_ENV_VAR not in result.output class TestApiKeyPrecedence: