diff --git a/docs/decisions/019-auth-logout-clear-and-stale-token-detection.md b/docs/decisions/019-auth-logout-clear-and-stale-token-detection.md new file mode 100644 index 0000000..f7661ae --- /dev/null +++ b/docs/decisions/019-auth-logout-clear-and-stale-token-detection.md @@ -0,0 +1,124 @@ +--- +id: 019 +title: Auth logout/clear commands and stale-token detection +status: accepted +date: 2026-04-27 +supersedes: [] +superseded_by: null +tags: [auth, cli, recoverability, keyring] +--- + +# ADR-019: Auth logout/clear commands and stale-token detection + +## Context + +Desk's auth state lives in the OS keychain (per ADR-012) under two keys: `client:credentials` (the OAuth client config) and `oauth:token` (the user's refreshable token). When these get out of sync — e.g. the stored token was minted against a different OAuth client than the one currently configured, or scopes drift after a `SCOPES` change — `desk` will fail with refresh errors and the user has no first-class way to recover. + +Today, the only way to fully reset is manual keychain surgery using the `security` CLI (and digging through Keychain Access). The underlying keyring helpers (`keyring_store.delete_token()`, `keyring_store.clear_all()`) already exist but are not exposed via the CLI. `auth status` doesn't surface enough to even diagnose the mismatch — it shows "credentials in keyring" as a boolean but not the `client_id` or scopes. + +This is a high-severity recoverability issue: when auth breaks, the user is stuck. + +## Decision + +Add two new commands and enrich `auth status`: + +### `desk auth logout` + +- Removes only the OAuth token from the keychain (preserves client config). +- Idempotent: prints "no token to remove" rather than erroring when nothing is stored. +- Also scrubs any legacy `~/.desk/token.json` so a stale plaintext token can't keep authenticating after logout. +- Supports `--json` for structured output. + +### `desk auth clear` + +- Removes both the OAuth token and the stored client credentials. +- Confirmation prompt by default; `--yes` to skip. +- In non-interactive mode (no TTY), require `--yes` rather than hanging on the prompt — same pattern as `desk docs delete-tab`. +- `--token` flag to clear only the token. +- `--client` flag to clear only the client config. +- Passing both flags is equivalent to passing neither (clears both). +- Supports `--json`. + +### `auth status` enrichments + +Adds the following fields to the status payload: + +- `client_id`: The OAuth client_id currently configured (from keyring client credentials, or bundled credentials, or token if that's the only source). This is the single most useful field for diagnosing "which client am I authenticating against?" +- `token_client_id`: The client_id baked into the stored token, if different from the configured one. When these differ, the token will not refresh. +- `token_source`: Where the token came from — `keyring`, `file`, `gcloud_adc`, or `none`. +- `scopes`: The scopes attached to the stored token (list of strings). + +### Stale-token detection on `set-client` + +When `desk auth set-client` runs and the new `client_id` differs from the `client_id` baked into the existing token, automatically delete the stored token (it cannot refresh against a different client). Print a one-line note: `Cleared stored token (was issued for a different client_id).` Only emit the note when a token actually existed and was deleted. + +## Alternatives Considered + +### Alternative 1: Document the manual keychain surgery in README + +**Description**: Tell users to run `security delete-generic-password -s desk-google -a oauth:token` when things break. + +**Pros**: +- Zero implementation cost. + +**Cons**: +- Requires platform-specific knowledge (Linux uses SecretService, Windows Credential Locker). +- Users who hit this aren't going to read docs first. +- Yahoo security has flagged broad use of the `security` CLI as a risk. + +**Why rejected**: Not a real solution. The capability exists in the codebase already; just expose it. + +### Alternative 2: `auth reset` instead of `logout` + `clear` + +**Description**: One command with subcommands or modes. + +**Pros**: +- Single entry point. + +**Cons**: +- "logout" is the universal verb users reach for first; not having it is surprising. +- Conflates the common case (sign out, keep client config) with the recovery case (nuke everything). + +**Why rejected**: `logout` is muscle memory. `clear` signals "more destructive". Two commands with clear distinct semantics is better. + +### Alternative 3: Make `set-client` invalidation opt-in via a flag + +**Description**: Require `--reset-token` on `set-client` to clear the existing token. + +**Pros**: +- Explicit. + +**Cons**: +- The token literally cannot work against a different client_id. Keeping it stored is misleading state, not "preserved" state. +- Users who hit this case will be confused about why login is failing after set-client. + +**Why rejected**: Auto-invalidation is the safer default. The one-line stdout note keeps it visible. + +## Consequences + +### Positive + +- Users have a clean recovery path when auth breaks. +- `auth status` is now actually diagnostic (you can see the client_id mismatch instead of guessing). +- `set-client` is self-healing for the most common failure mode it causes. +- No more `security` CLI surgery required. + +### Negative + +- Slightly larger CLI surface (two new commands, more fields on status). +- `auth status` now reads from keyring more aggressively, which may add a small latency on platforms with slow keyring backends (negligible in practice). + +### Neutral + +- The legacy `~/.desk/token.json` fallback still exists (per ADR-012's migration path); `logout` now scrubs it explicitly so the semantics match user expectation. + +## Implementation Notes + +- New helper `keyring_store.delete_client_credentials()` mirrors `delete_token()` (idempotent, returns bool). +- `auth_status` reads from a new helper `_get_status_details()` that resolves `client_id`, `token_client_id`, `token_source`, and `scopes`. +- Tests cover: logout idempotence, clear flag matrix, non-interactive `--yes` enforcement, status field shape, set-client invalidation path. + +## References + +- ADR-012: OS Keychain Credential Storage +- `src/desk/auth.py`, `src/desk/cli.py`, `src/desk/keyring_store.py` diff --git a/src/desk/auth.py b/src/desk/auth.py index dcf0406..ead0c44 100644 --- a/src/desk/auth.py +++ b/src/desk/auth.py @@ -141,9 +141,7 @@ def _get_oauth_credentials() -> Credentials | None: return None except Exception as e: _logger.debug(f"Unexpected error loading token file: {type(e).__name__}: {e}") - _last_auth_failure["reason"] = ( - f"Could not load token file: {type(e).__name__}: {e}" - ) + _last_auth_failure["reason"] = f"Could not load token file: {type(e).__name__}: {e}" _last_auth_failure["error_code"] = "AUTH_INVALID" return None # Migrate to keyring @@ -185,8 +183,7 @@ def _get_oauth_credentials() -> Credentials | None: if not creds.refresh_token: _last_auth_failure["reason"] = ( - "Token expired and no refresh token." - " Run `desk auth login` to re-authenticate." + "Token expired and no refresh token. Run `desk auth login` to re-authenticate." ) _last_auth_failure["error_code"] = "AUTH_EXPIRED" else: @@ -360,6 +357,112 @@ def _save_credentials(creds: Credentials) -> None: TOKEN_FILE.write_text(json_module.dumps(scrubbed)) +def _get_configured_client_id() -> str | None: + """Return the client_id of the OAuth client desk is currently configured to use. + + Resolution order matches login(): + 1. Keyring client credentials + 2. ~/.desk/credentials.json (file) + 3. Bundled credentials + """ + keyring_creds = keyring_store.get_client_credentials() + if keyring_creds: + return keyring_creds.get("installed", {}).get("client_id") + + if CREDENTIALS_FILE.exists(): + try: + data = json_module.loads(CREDENTIALS_FILE.read_text()) + return data.get("installed", {}).get("client_id") + except (json_module.JSONDecodeError, OSError): + pass + + bundled = get_bundled_credentials() + if bundled: + return bundled.get("installed", {}).get("client_id") + + return None + + +def _get_token_source_and_data() -> tuple[str, dict | None]: + """Return (source, token_dict) for the active OAuth token. + + source is one of: "keyring", "file", "none". + token_dict is the raw JSON dict, useful for surfacing client_id/scopes. + """ + keyring_token = keyring_store.get_token() + if keyring_token: + return ("keyring", keyring_token) + + if TOKEN_FILE.exists(): + try: + data = json_module.loads(TOKEN_FILE.read_text()) + except (json_module.JSONDecodeError, OSError): + return ("none", None) + if "token" in data or "refresh_token" in data: + return ("file", data) + return ("none", None) + + +def _normalize_scopes(raw: object) -> list[str]: + """Normalize a `scopes` field which may be None, a string, or a list.""" + if raw is None: + return [] + if isinstance(raw, str): + # Google sometimes serializes scopes as a space-delimited string. + return [s for s in raw.split() if s] + if isinstance(raw, list): + return [str(s) for s in raw] + return [] + + +def logout() -> dict: + """Remove the OAuth token from the keychain (and legacy file). + + Idempotent. Preserves stored client credentials. Returns a dict describing + what was removed: {"keyring_token_removed": bool, "token_file_scrubbed": bool}. + """ + keyring_removed = keyring_store.delete_token() + + file_scrubbed = False + if TOKEN_FILE.exists(): + try: + data = json_module.loads(TOKEN_FILE.read_text()) + except (json_module.JSONDecodeError, OSError): + data = None + if isinstance(data, dict) and any(field in data for field in _TOKEN_SENSITIVE_FIELDS): + scrubbed = {k: v for k, v in data.items() if k not in _TOKEN_SENSITIVE_FIELDS} + TOKEN_FILE.write_text(json_module.dumps(scrubbed)) + file_scrubbed = True + + return { + "keyring_token_removed": keyring_removed, + "token_file_scrubbed": file_scrubbed, + } + + +def clear(token: bool = True, client: bool = True) -> dict: + """Remove credentials from the keychain. + + Args: + token: If True, remove the OAuth token. + client: If True, remove the stored OAuth client credentials. + + Returns a dict describing what was removed. + """ + result: dict[str, bool] = { + "keyring_token_removed": False, + "token_file_scrubbed": False, + "keyring_client_removed": False, + } + if token: + token_result = logout() + result["keyring_token_removed"] = token_result["keyring_token_removed"] + result["token_file_scrubbed"] = token_result["token_file_scrubbed"] + if client: + result["keyring_client_removed"] = keyring_store.delete_client_credentials() + return result + + def get_auth_status(verify: bool = False) -> dict: """Get current authentication status. @@ -367,6 +470,10 @@ def get_auth_status(verify: bool = False) -> dict: verify: If True, test actual API access for each service (slower but accurate) """ gcloud_available = _gcloud_available() + configured_client_id = _get_configured_client_id() + token_source, token_data = _get_token_source_and_data() + token_client_id = token_data.get("client_id") if token_data else None + token_scopes = _normalize_scopes(token_data.get("scopes")) if token_data else [] status = { "method": AuthMethod.NONE, @@ -378,6 +485,10 @@ def get_auth_status(verify: bool = False) -> dict: "token_file": TOKEN_FILE.exists(), "token_in_keyring": keyring_store.get_token() is not None, "token_path": str(TOKEN_FILE), + "client_id": configured_client_id, + "token_client_id": token_client_id, + "token_source": token_source, + "scopes": token_scopes, "email": None, "services": None, # Populated if verify=True } @@ -396,6 +507,10 @@ def get_auth_status(verify: bool = False) -> dict: if creds: status["authenticated"] = True status["method"] = AuthMethod.GCLOUD_ADC + # When ADC is the working source and no other token exists, attribute + # the token source accordingly so users can tell where auth is coming from. + if status["token_source"] == "none": + status["token_source"] = "gcloud_adc" if verify: status["services"] = verify_service_access(creds) return status diff --git a/src/desk/cli.py b/src/desk/cli.py index 67740e7..c54ec09 100644 --- a/src/desk/cli.py +++ b/src/desk/cli.py @@ -19,6 +19,12 @@ login, login_with_gcloud, ) +from desk.auth import ( + clear as auth_clear_action, +) +from desk.auth import ( + logout as auth_logout_action, +) from desk.config import CONFIG_DIR, CREDENTIALS_FILE, migrate_legacy_config console = Console() @@ -333,8 +339,22 @@ def auth_set_client(client_id: str, client_secret: str, project_id: str | None) if project_id: credentials["installed"]["project_id"] = project_id + # If the new client_id differs from the existing token's client_id, the + # stored token can no longer refresh. Auto-invalidate it so the user lands + # in a clean state on the next login. + existing_token = keyring_store.get_token() + invalidated_token = False + if existing_token: + existing_client_id = existing_token.get("client_id") + if existing_client_id and existing_client_id != client_id: + invalidated_token = keyring_store.delete_token() + keyring_store.set_client_credentials(credentials) console.print("Client credentials stored in keychain.") + if invalidated_token: + console.print( + "Cleared stored token (was issued for a different client_id)." + ) @auth.command("login") @@ -386,6 +406,28 @@ def auth_status(as_json: bool, verify: bool) -> None: console.print(f"[green]Authenticated[/green] via {method_display}") + # Show OAuth client + token diagnostics so users can spot stale state. + client_id = info.get("client_id") + token_client_id = info.get("token_client_id") + token_source = info.get("token_source") + scopes = info.get("scopes") or [] + + if client_id: + console.print(f" client_id: {escape(client_id)}", soft_wrap=True) + if token_client_id and token_client_id != client_id: + console.print( + f" [yellow]token client_id: {escape(token_client_id)} " + "(does not match configured client — " + "run `desk auth login` to refresh)[/yellow]", + soft_wrap=True, + ) + if token_source and token_source != "none": + console.print(f" token source: {token_source}") + if scopes: + console.print(f" scopes ({len(scopes)}):") + for scope in scopes: + console.print(f" {escape(scope)}") + # Show service access if verified if info.get("services"): console.print() @@ -417,6 +459,105 @@ def auth_status(as_json: bool, verify: bool) -> None: console.print("Run: [cyan]desk setup[/cyan] for setup instructions") +@auth.command("logout") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +def auth_logout(as_json: bool) -> None: + """Sign out by removing the stored OAuth token. + + Removes the OAuth token from the OS keychain (and scrubs any legacy + ~/.desk/token.json secrets). Stored client credentials are preserved so + you can run `desk auth login` again without re-provisioning the client. + + Idempotent: succeeds even if no token is stored. + """ + result = auth_logout_action() + + if as_json: + print(json.dumps(result, indent=2)) + return + + if result["keyring_token_removed"] or result["token_file_scrubbed"]: + if result["keyring_token_removed"]: + console.print("[green]Removed OAuth token from keychain.[/green]") + if result["token_file_scrubbed"]: + console.print("[green]Scrubbed legacy token file.[/green]") + console.print("Run [cyan]desk auth login[/cyan] to sign in again.") + else: + console.print("[dim]No stored OAuth token to remove.[/dim]") + + +@auth.command("clear") +@click.option("--token", "clear_token", is_flag=True, help="Clear only the OAuth token") +@click.option( + "--client", "clear_client_flag", is_flag=True, help="Clear only the client credentials" +) +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +def auth_clear( + clear_token: bool, clear_client_flag: bool, yes: bool, as_json: bool +) -> None: + """Remove stored credentials from the OS keychain. + + By default, clears both the OAuth token and the stored client credentials. + Use --token to clear only the token, or --client to clear only the client + credentials. Passing both flags is equivalent to passing neither. + + This is the recovery hatch when the keychain state is stale (e.g. the + stored token was minted against a different OAuth client than the one + currently configured, or scopes have drifted). + """ + # Default: both. Both flags: both. Otherwise honor the explicit flag. + if clear_token == clear_client_flag: + do_token = True + do_client = True + else: + do_token = clear_token + do_client = clear_client_flag + + targets = [] + if do_token: + targets.append("OAuth token") + if do_client: + targets.append("client credentials") + target_label = " and ".join(targets) + + if not yes: + if not sys.stdin.isatty(): + if as_json: + print( + json.dumps( + { + "error": "Non-interactive mode requires --yes flag", + "targets": targets, + }, + indent=2, + ) + ) + else: + console.print("[red]Error: Non-interactive mode requires --yes flag[/red]") + sys.exit(1) + if not click.confirm(f"Remove {target_label} from keychain?"): + console.print("[yellow]Cancelled[/yellow]") + return + + result = auth_clear_action(token=do_token, client=do_client) + + if as_json: + print(json.dumps(result, indent=2)) + return + + if result["keyring_token_removed"]: + console.print("[green]Removed OAuth token from keychain.[/green]") + if result["token_file_scrubbed"]: + console.print("[green]Scrubbed legacy token file.[/green]") + if result["keyring_client_removed"]: + console.print("[green]Removed client credentials from keychain.[/green]") + if not any(result.values()): + console.print("[dim]Nothing to remove.[/dim]") + else: + console.print("Run [cyan]desk setup[/cyan] to re-authenticate.") + + # --- Register subcommand groups --- from desk.commands.cal import cal # noqa: E402 diff --git a/src/desk/keyring_store.py b/src/desk/keyring_store.py index e90f051..61d7731 100644 --- a/src/desk/keyring_store.py +++ b/src/desk/keyring_store.py @@ -82,6 +82,20 @@ def delete_token() -> bool: return False +def delete_client_credentials() -> bool: + """Delete the stored OAuth client credentials from keyring. + + Returns True if they existed and were removed. + """ + try: + if keyring.get_password(KEYRING_SERVICE, "client:credentials") is not None: + keyring.delete_password(KEYRING_SERVICE, "client:credentials") + return True + except keyring.errors.PasswordDeleteError: + pass + return False + + def clear_all() -> bool: """Delete all desk credentials from keyring. Returns True if anything was deleted.""" deleted = False diff --git a/tests/test_auth_logout_clear.py b/tests/test_auth_logout_clear.py new file mode 100644 index 0000000..e77006b --- /dev/null +++ b/tests/test_auth_logout_clear.py @@ -0,0 +1,381 @@ +"""Tests for `desk auth logout`, `desk auth clear`, enriched `auth status`, +and stale-token detection on `desk auth set-client`. +""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from desk.keyring_store import KEYRING_SERVICE + + +@pytest.fixture +def fake_keyring(): + """In-memory keyring substitute. Mirrors the fixture in test_keyring.py.""" + store: dict[tuple[str, str], str] = {} + + def get_password(service: str, key: str) -> str | None: + return store.get((service, key)) + + def set_password(service: str, key: str, value: str) -> None: + store[(service, key)] = value + + def delete_password(service: str, key: str) -> None: + if (service, key) not in store: + import keyring.errors + + raise keyring.errors.PasswordDeleteError() + del store[(service, key)] + + with ( + patch("desk.keyring_store.keyring.get_password", side_effect=get_password), + patch("desk.keyring_store.keyring.set_password", side_effect=set_password), + patch("desk.keyring_store.keyring.delete_password", side_effect=delete_password), + ): + yield store + + +@pytest.fixture +def isolated_token_file(tmp_path): + """Redirect TOKEN_FILE and CREDENTIALS_FILE into a tmpdir.""" + token_path = tmp_path / "token.json" + creds_path = tmp_path / "credentials.json" + with ( + patch("desk.auth.TOKEN_FILE", token_path), + patch("desk.auth.CREDENTIALS_FILE", creds_path), + ): + yield {"token": token_path, "credentials": creds_path} + + +def _seed_token(store: dict, token: dict) -> None: + store[(KEYRING_SERVICE, "oauth:token")] = json.dumps(token) + + +def _seed_client(store: dict, client_id: str = "configured.apps.googleusercontent.com") -> None: + store[(KEYRING_SERVICE, "client:credentials")] = json.dumps( + { + "installed": { + "client_id": client_id, + "client_secret": "GOCSPX-secret", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + } + } + ) + + +class TestKeyringDeleteClient: + def test_delete_client_credentials_when_present(self, fake_keyring): + from desk.keyring_store import delete_client_credentials + + _seed_client(fake_keyring) + assert delete_client_credentials() is True + assert (KEYRING_SERVICE, "client:credentials") not in fake_keyring + + def test_delete_client_credentials_idempotent(self, fake_keyring): + from desk.keyring_store import delete_client_credentials + + assert delete_client_credentials() is False + + +class TestLogoutCommand: + def test_logout_removes_keyring_token(self, fake_keyring, isolated_token_file): + _seed_token(fake_keyring, {"token": "ya29.abc", "refresh_token": "1//xyz"}) + + from desk.cli import main + + result = CliRunner().invoke(main, ["auth", "logout"]) + assert result.exit_code == 0, result.output + assert "Removed OAuth token" in result.output + assert (KEYRING_SERVICE, "oauth:token") not in fake_keyring + + def test_logout_idempotent(self, fake_keyring, isolated_token_file): + from desk.cli import main + + result = CliRunner().invoke(main, ["auth", "logout"]) + assert result.exit_code == 0 + assert "No stored OAuth token" in result.output + + def test_logout_preserves_client_credentials(self, fake_keyring, isolated_token_file): + _seed_client(fake_keyring) + _seed_token(fake_keyring, {"token": "ya29.abc"}) + + from desk.cli import main + + result = CliRunner().invoke(main, ["auth", "logout"]) + assert result.exit_code == 0 + assert (KEYRING_SERVICE, "client:credentials") in fake_keyring + + def test_logout_scrubs_legacy_token_file(self, fake_keyring, isolated_token_file): + token_path = isolated_token_file["token"] + token_path.write_text( + json.dumps( + { + "token": "ya29.legacy", + "refresh_token": "1//legacy", + "client_id": "id.apps.googleusercontent.com", + "client_secret": "GOCSPX-legacy", + "scopes": ["scope-a"], + } + ) + ) + + from desk.cli import main + + result = CliRunner().invoke(main, ["auth", "logout"]) + assert result.exit_code == 0 + assert "Scrubbed legacy token file" in result.output + + remaining = json.loads(token_path.read_text()) + assert "token" not in remaining + assert "refresh_token" not in remaining + assert "client_secret" not in remaining + # Non-secret metadata is preserved + assert remaining["scopes"] == ["scope-a"] + + def test_logout_json_output(self, fake_keyring, isolated_token_file): + _seed_token(fake_keyring, {"token": "ya29.abc"}) + + from desk.cli import main + + result = CliRunner().invoke(main, ["auth", "logout", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["keyring_token_removed"] is True + assert payload["token_file_scrubbed"] is False + + +class TestClearCommand: + def test_clear_default_removes_both(self, fake_keyring, isolated_token_file): + _seed_token(fake_keyring, {"token": "ya29.abc"}) + _seed_client(fake_keyring) + + from desk.cli import main + + result = CliRunner().invoke(main, ["auth", "clear", "--yes"]) + assert result.exit_code == 0, result.output + assert (KEYRING_SERVICE, "oauth:token") not in fake_keyring + assert (KEYRING_SERVICE, "client:credentials") not in fake_keyring + + def test_clear_token_only(self, fake_keyring, isolated_token_file): + _seed_token(fake_keyring, {"token": "ya29.abc"}) + _seed_client(fake_keyring) + + from desk.cli import main + + result = CliRunner().invoke(main, ["auth", "clear", "--token", "--yes"]) + assert result.exit_code == 0, result.output + assert (KEYRING_SERVICE, "oauth:token") not in fake_keyring + assert (KEYRING_SERVICE, "client:credentials") in fake_keyring + + def test_clear_client_only(self, fake_keyring, isolated_token_file): + _seed_token(fake_keyring, {"token": "ya29.abc"}) + _seed_client(fake_keyring) + + from desk.cli import main + + result = CliRunner().invoke(main, ["auth", "clear", "--client", "--yes"]) + assert result.exit_code == 0, result.output + assert (KEYRING_SERVICE, "oauth:token") in fake_keyring + assert (KEYRING_SERVICE, "client:credentials") not in fake_keyring + + def test_clear_both_flags_same_as_default(self, fake_keyring, isolated_token_file): + _seed_token(fake_keyring, {"token": "ya29.abc"}) + _seed_client(fake_keyring) + + from desk.cli import main + + result = CliRunner().invoke(main, ["auth", "clear", "--token", "--client", "--yes"]) + assert result.exit_code == 0, result.output + assert (KEYRING_SERVICE, "oauth:token") not in fake_keyring + assert (KEYRING_SERVICE, "client:credentials") not in fake_keyring + + def test_clear_non_interactive_requires_yes(self, fake_keyring, isolated_token_file): + _seed_token(fake_keyring, {"token": "ya29.abc"}) + + from desk.cli import main + + # CliRunner provides no TTY by default, simulating CI/scripts. + result = CliRunner().invoke(main, ["auth", "clear"]) + assert result.exit_code != 0 + assert "Non-interactive mode requires --yes flag" in result.output + # Token must be untouched + assert (KEYRING_SERVICE, "oauth:token") in fake_keyring + + def test_clear_non_interactive_json(self, fake_keyring, isolated_token_file): + from desk.cli import main + + result = CliRunner().invoke(main, ["auth", "clear", "--json"]) + assert result.exit_code != 0 + payload = json.loads(result.output) + assert "Non-interactive" in payload["error"] + + def test_clear_idempotent(self, fake_keyring, isolated_token_file): + from desk.cli import main + + result = CliRunner().invoke(main, ["auth", "clear", "--yes"]) + assert result.exit_code == 0 + assert "Nothing to remove" in result.output + + def test_clear_json_output(self, fake_keyring, isolated_token_file): + _seed_token(fake_keyring, {"token": "ya29.abc"}) + _seed_client(fake_keyring) + + from desk.cli import main + + result = CliRunner().invoke(main, ["auth", "clear", "--yes", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["keyring_token_removed"] is True + assert payload["keyring_client_removed"] is True + + +class TestStatusFields: + def test_status_surfaces_client_id_and_scopes(self, fake_keyring, isolated_token_file): + _seed_client(fake_keyring, "configured.apps.googleusercontent.com") + _seed_token( + fake_keyring, + { + "token": "ya29.abc", + "refresh_token": "1//xyz", + "client_id": "configured.apps.googleusercontent.com", + "client_secret": "GOCSPX-x", + "token_uri": "https://oauth2.googleapis.com/token", + "scopes": [ + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/drive", + ], + }, + ) + + from desk.auth import get_auth_status + + info = get_auth_status() + assert info["client_id"] == "configured.apps.googleusercontent.com" + assert info["token_client_id"] == "configured.apps.googleusercontent.com" + assert info["token_source"] == "keyring" + assert info["scopes"] == [ + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/drive", + ] + + def test_status_flags_client_id_mismatch(self, fake_keyring, isolated_token_file): + _seed_client(fake_keyring, "new.apps.googleusercontent.com") + _seed_token( + fake_keyring, + { + "token": "ya29.abc", + "client_id": "old.apps.googleusercontent.com", + "scopes": ["scope-a"], + }, + ) + + from desk.auth import get_auth_status + + info = get_auth_status() + assert info["client_id"] == "new.apps.googleusercontent.com" + assert info["token_client_id"] == "old.apps.googleusercontent.com" + + def test_status_no_token_no_client(self, fake_keyring, isolated_token_file): + from desk.auth import get_auth_status + + # Avoid finding bundled credentials or live gcloud ADC in the test env. + with ( + patch("desk.auth.get_bundled_credentials", return_value=None), + patch("desk.auth._get_adc_credentials", return_value=None), + ): + info = get_auth_status() + assert info["client_id"] is None + assert info["token_client_id"] is None + assert info["token_source"] == "none" + assert info["scopes"] == [] + + def test_status_normalizes_string_scopes(self, fake_keyring, isolated_token_file): + _seed_token( + fake_keyring, + { + "token": "ya29.abc", + "scopes": "scope-a scope-b scope-c", + }, + ) + + from desk.auth import get_auth_status + + info = get_auth_status() + assert info["scopes"] == ["scope-a", "scope-b", "scope-c"] + + +class TestSetClientStaleToken: + def test_set_client_invalidates_stale_token(self, fake_keyring): + _seed_token( + fake_keyring, + { + "token": "ya29.abc", + "refresh_token": "1//xyz", + "client_id": "old.apps.googleusercontent.com", + }, + ) + + from desk.cli import main + + result = CliRunner().invoke( + main, + [ + "auth", + "set-client", + "--client-id", + "new.apps.googleusercontent.com", + "--client-secret", + "GOCSPX-new", + ], + ) + assert result.exit_code == 0, result.output + assert "Cleared stored token" in result.output + assert (KEYRING_SERVICE, "oauth:token") not in fake_keyring + + def test_set_client_keeps_matching_token(self, fake_keyring): + _seed_token( + fake_keyring, + { + "token": "ya29.abc", + "client_id": "same.apps.googleusercontent.com", + }, + ) + + from desk.cli import main + + result = CliRunner().invoke( + main, + [ + "auth", + "set-client", + "--client-id", + "same.apps.googleusercontent.com", + "--client-secret", + "GOCSPX-x", + ], + ) + assert result.exit_code == 0, result.output + assert "Cleared stored token" not in result.output + assert (KEYRING_SERVICE, "oauth:token") in fake_keyring + + def test_set_client_no_existing_token_no_note(self, fake_keyring): + from desk.cli import main + + result = CliRunner().invoke( + main, + [ + "auth", + "set-client", + "--client-id", + "fresh.apps.googleusercontent.com", + "--client-secret", + "GOCSPX-x", + ], + ) + assert result.exit_code == 0, result.output + assert "Cleared stored token" not in result.output