From f35d2b6fce6cf6e1501c38155747dae9768366ac Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Wed, 15 Jul 2026 10:27:02 +0200 Subject: [PATCH 01/29] feat(bfabric_scripts): make `auth default` an interactive env picker Collapse `auth default set [ENV]` into the bare `auth default`: with no argument it opens an arrow-key picker (type to filter, Enter to select), each row showing the environment's host and auth method. A positional `ENV` still sets the default non-interactively, and with no TTY the command lists the environments instead of prompting. Add a small reusable `cli.interactive` helper around questionary (`resolve_choice` / `select_choice` / `select_or_input`) covering the value-on-CLI / menu / type-a-new-value pattern for future commands. --- bfabric_scripts/docs/changelog.md | 4 +- bfabric_scripts/pyproject.toml | 1 + .../src/bfabric_scripts/cli/interactive.py | 89 ++++++++++++++ .../cli/login/default_config.py | 116 ++++++++++-------- .../cli/login/test_cmd_auth_default.py | 84 ++++++++----- tests/bfabric_scripts/cli/test_interactive.py | 74 +++++++++++ 6 files changed, 279 insertions(+), 89 deletions(-) create mode 100644 bfabric_scripts/src/bfabric_scripts/cli/interactive.py create mode 100644 tests/bfabric_scripts/cli/test_interactive.py diff --git a/bfabric_scripts/docs/changelog.md b/bfabric_scripts/docs/changelog.md index 061e23f6..7309182d 100644 --- a/bfabric_scripts/docs/changelog.md +++ b/bfabric_scripts/docs/changelog.md @@ -15,14 +15,14 @@ Versioning currently follows `X.Y.Z` semantic versioning, independent of the `bf - `bfabric-cli auth` command group for OAuth authentication and client management: - Login: `auth pkce` (browser), `auth device-code` (headless), `auth pat` (Personal Access Token). - `auth register` / `auth register-webapp` — dynamic client registration, optionally with a linked B-Fabric app. - - `auth default` / `auth default set [CONFIG_ENV]` — show and set the default environment (interactive picker when no value is given). + - `auth default [CONFIG_ENV]` — set the default environment; an arrow-key interactive picker (navigate the list or type to filter, Enter to select; each row shows the host and auth method) opens when no value is given, or lists the environments in a non-interactive context. - `auth status` and `auth logout`. - `auth pat` stores the token under a `pat` key with `auth_method: pat` (not `login: __oauth__` / `password:`), keeping the shared config parseable by older (≤1.19.0) clients; `auth status` reports these as `pat`. - `bfabric-cli workunit upload FILES...` — upload files and directories to a workunit over tus (resumable, large-file capable), creating a new workunit or targeting `--workunit-id`. One resource per file, skips duplicates (unless `--force`), expands directories, live progress (`--no-progress`); `--track-job` records an `UPLOAD` job. Requires an OAuth client with the `tus` scope. - `bfabric-cli api create` / `api update` accept `--format json|yaml|tsv|table_rich` (default `json`), matching `api read`. - `bfabric-cli dataset update` — update an existing dataset with a change preview before confirming (`csv`/`tsv`/`xlsx`/`parquet`, same validation flags as `dataset upload`). - `api create` / `api update` now emit valid JSON (was Python `repr` via `rich.pretty.pprint`, breaking `jq`) and serialise `datetime` / `Decimal` to strings instead of raising `TypeError` ([#503](https://github.com/fgcz/bfabricPy/issues/503)). -- Internal: `dataset upload` and `bfabric_save_csv2dataset.py` now use `bfabric.operations.dataset.create_dataset`; API error handling is centralized in `@use_client` (no more `@logger.catch`); `--config-env` naming unified. Declares `lxml` as an explicit dependency (was transitive via the now-optional `zeep`) and requires `bfabric[transfer]` >= 1.20 (for `upload_files` / tus). +- Internal: `dataset upload` and `bfabric_save_csv2dataset.py` now use `bfabric.operations.dataset.create_dataset`; API error handling is centralized in `@use_client` (no more `@logger.catch`); `--config-env` naming unified. Declares `lxml` as an explicit dependency (was transitive via the now-optional `zeep`) and requires `bfabric[transfer]` >= 1.20 (for `upload_files` / tus). Adds `questionary` for interactive CLI prompts, wrapped in a reusable `cli.interactive` helper (`resolve_choice` / `select_choice` / `select_or_input`). ## \[1.15.0\] - 2026-04-20 diff --git a/bfabric_scripts/pyproject.toml b/bfabric_scripts/pyproject.toml index 0c1d0bb1..9d0eae1f 100644 --- a/bfabric_scripts/pyproject.toml +++ b/bfabric_scripts/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "bfabric[transfer]>=1.20.0rc2,<2", "cyclopts>=4.0,<5.0", "Flask>=3.0.3,<4.0", + "questionary>=2.0,<3", "rich>=13.0,<15", "lxml>=5.0,<7.0", "xmltodict>=1.0.0,<2.0.0", diff --git a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py new file mode 100644 index 00000000..36590c17 --- /dev/null +++ b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py @@ -0,0 +1,89 @@ +"""Reusable interactive prompt helpers built on ``questionary``. + +These wrap the recurring CLI pattern where a value may be supplied on the command line, +selected from a menu, or (optionally) typed as a new value not in the list. Interactive +prompts require a TTY; call sites in non-interactive contexts (pipes, CI) either pass an +explicit value or handle the ``None`` returned by :func:`resolve_choice`. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable, Sequence +from typing import cast + +import questionary + + +def is_interactive() -> bool: + """Whether we can drive an interactive prompt (both ends attached to a terminal).""" + return sys.stdin.isatty() and sys.stdout.isatty() + + +def select_choice( + message: str, + choices: Sequence[str], + *, + default: str | None = None, + describe: Callable[[str], str] | None = None, + search: bool = False, +) -> str | None: + """Show an arrow-key menu of *choices* and return the picked value. + + Returns ``None`` if the user cancels (Ctrl-C / Esc). *default* pre-selects an entry and + must be one of *choices* (pass ``None`` otherwise). *describe* maps each value to the label + shown in the menu (e.g. to append a host or auth method); the return value is still the + plain choice, never its label. With *search*, the user can type to filter the list live + (arrow keys still work on the filtered subset) -- handy for long lists. + """ + items: list[str | questionary.Choice] + if describe is not None: + items = [questionary.Choice(title=describe(choice), value=choice) for choice in choices] + else: + items = list(choices) + # questionary's ``ask()`` is typed ``Any``; the prompt only ever yields a choice or None. + # With the search filter on, j/k must stop being navigation keys (they'd be swallowed as + # filter input) -- questionary raises otherwise. Arrow keys keep working regardless. + return cast( + "str | None", + questionary.select( + message, choices=items, default=default, use_search_filter=search, use_jk_keys=not search + ).ask(), + ) + + +def select_or_input(message: str, choices: Sequence[str], *, default: str | None = None) -> str | None: + """Offer *choices* as autocomplete suggestions but let the user type a value not in the list. + + Returns the entered value, or ``None`` if the user cancels or submits an empty answer. + """ + # questionary's ``ask()`` is typed ``Any``; the autocomplete prompt yields the text or None. + answer = cast("str | None", questionary.autocomplete(message, choices=list(choices), default=default or "").ask()) + return answer or None + + +def resolve_choice( + value: str | None, + choices: Sequence[str], + *, + message: str, + allow_new: bool = False, + default: str | None = None, + describe: Callable[[str], str] | None = None, + search: bool = False, +) -> str | None: + """Resolve a choice from an explicit *value* or, failing that, an interactive prompt. + + * *value* given -> return it verbatim (the caller validates membership if it needs to). + * else, no TTY -> return ``None`` (the caller reports the non-interactive case). + * else, *allow_new* -> :func:`select_or_input` (pick a suggestion or type a new value). + * else -> :func:`select_choice` (menu; *describe* labels each entry, *search* enables + type-to-filter). + """ + if value is not None: + return value + if not is_interactive(): + return None + if allow_new: + return select_or_input(message, choices, default=default) + return select_choice(message, choices, default=default, describe=describe, search=search) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py b/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py index af8170a4..86f98913 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py @@ -1,88 +1,79 @@ -"""Commands to show and set the default configuration environment.""" +"""Command to show and set the default configuration environment.""" from __future__ import annotations from pathlib import Path from typing import Annotated +from urllib.parse import urlsplit import cyclopts import yaml from rich.console import Console -from rich.prompt import Prompt from rich.text import Text from bfabric.config import DEFAULT_CONFIG_FILE -from bfabric.config.config_file import ConfigFile +from bfabric.config.config_file import ConfigFile, EnvironmentConfig from bfabric.config.config_writer import set_default_config +from bfabric_scripts.cli.interactive import is_interactive, resolve_choice -cmd_auth_default = cyclopts.App(help="Show or set the default configuration environment.") +def _auth_method(env: EnvironmentConfig) -> str: + """Label an environment's auth method, mirroring ``auth status``' precedence.""" + if env.auth_method in ("oauth", "pat"): + return env.auth_method + if env.auth is not None: + return "password" + return "none" -def _environment_line(name: str, *, index: int | None, is_default: bool) -> Text: + +def _environment_summary(env: EnvironmentConfig) -> str: + """A compact "host · auth-method" descriptor shown next to each environment name.""" + base_url = str(env.config.base_url) + host = urlsplit(base_url).netloc or base_url + return f"{host} · {_auth_method(env)}" + + +def _environment_line(name: str, *, summary: str, width: int, is_default: bool) -> Text: """Render one environment row. Text (not markup) keeps names with "[" literal. - The current default is prefixed with a bold-green arrow so it stands out in a long list. + The current default is prefixed with a bold-green arrow so it stands out in a long list; + the "host · auth-method" summary trails the (padded) name. """ - label = f"{index}. {name}" if index is not None else name + padded = name.ljust(width) if is_default: # Chained appends (each returns the Text) keep the whole row in one expression. - return Text("→ ", style="bold green").append(label, style="bold green").append(" (default)", style="green") - return Text(f" {label}") - - -def _prompt_for_environment(console: Console, names: list[str], default: str | None) -> str: - """Show a numbered menu of *names* and return the environment the user picks.""" - console.print("Configuration environments:") - for index, name in enumerate(names, start=1): - console.print(_environment_line(name, index=index, is_default=name == default)) - choices = [str(i) for i in range(1, len(names) + 1)] - # show_choices=False: the numbered menu above already lists the options, so the prompt stays - # short ("Select environment (3):") instead of repeating every name inline. Only offer a - # default when there is a current default to pre-select; otherwise the pick is required. - if default in names: - answer = Prompt.ask( - "Select environment", choices=choices, default=str(names.index(default) + 1), show_choices=False + return ( + Text("→ ", style="bold green") + .append(padded, style="bold green") + .append(f" {summary}", style="green") + .append(" (default)", style="green") ) - else: - answer = Prompt.ask("Select environment", choices=choices, show_choices=False) - return names[int(answer) - 1] - - -@cmd_auth_default.default -def cmd_auth_default_show( - *, - config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, -) -> None: - """List configuration environments and show the current default.""" - config_path = Path(config_file).expanduser() - if not config_path.is_file(): - print(f"Config file not found: {config_path}") - return + return Text(" ").append(padded).append(f" {summary}", style="dim") - config_file_obj = ConfigFile.model_validate(yaml.safe_load(config_path.read_text())) - names = list(config_file_obj.environments) - if not names: - print("No environments configured.") - return - default = config_file_obj.general.default_config - console = Console() +def _print_environments(console: Console, environments: dict[str, EnvironmentConfig], default: str | None) -> None: + """List the configured environments with their host/auth summary, marking the default.""" console.print("Configuration environments:") - for name in names: - console.print(_environment_line(name, index=None, is_default=name == default)) - if default is None: - console.print("\nNo default configured.") + width = max(len(name) for name in environments) + for name, env in environments.items(): + console.print( + _environment_line(name, summary=_environment_summary(env), width=width, is_default=name == default) + ) -@cmd_auth_default.command(name="set") -def cmd_auth_default_set( +def cmd_auth_default( config_env: Annotated[ - str | None, cyclopts.Parameter(help="Environment to set as default (prompted if omitted).") + str | None, + cyclopts.Parameter(help="Environment to set as default (interactive picker if omitted)."), ] = None, *, config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, ) -> None: - """Set the default configuration environment.""" + """Set the default configuration environment. + + With no *config_env*, opens an interactive picker (arrow keys to navigate, Enter to select) + in a terminal, or lists the environments in a non-interactive context. + """ config_path = Path(config_file).expanduser() if not config_path.is_file(): print(f"Config file not found: {config_path}") @@ -94,8 +85,25 @@ def cmd_auth_default_set( print("No environments configured.") return + default = config_file_obj.general.default_config + width = max(len(name) for name in names) + config_env = resolve_choice( + config_env, + names, + message="Select the default environment", + default=default if default in names else None, + describe=lambda name: f"{name.ljust(width)} {_environment_summary(config_file_obj.environments[name])}", + search=True, + ) if config_env is None: - config_env = _prompt_for_environment(Console(), names, config_file_obj.general.default_config) + # None means either the user cancelled the picker or there is no TTY to prompt on. + console = Console() + if is_interactive(): + console.print("No changes made.") + else: + _print_environments(console, config_file_obj.environments, default) + console.print("Run in an interactive terminal or pass an environment name to set the default.") + return if config_env not in config_file_obj.environments: print(f"Environment '{config_env}' not found. Available environments: {', '.join(names)}") diff --git a/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py b/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py index 6005f7e7..df149b90 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py @@ -3,7 +3,7 @@ import pytest import yaml -from bfabric_scripts.cli.login.default_config import cmd_auth_default_set, cmd_auth_default_show +from bfabric_scripts.cli.login.default_config import cmd_auth_default @pytest.fixture(autouse=True) @@ -17,63 +17,81 @@ def _write_config(config_file, default="PROD"): yaml.dump( { "GENERAL": general, - "PROD": {"base_url": "https://prod.example.com"}, - "TEST": {"base_url": "https://test.example.com"}, + "PROD": {"base_url": "https://prod.example.com", "auth_method": "oauth"}, + "TEST": {"base_url": "https://test.example.com", "auth_method": "pat", "pat": "tok123"}, } ) ) -class TestCmdAuthDefaultShow: - def test_lists_environments_and_marks_default(self, tmp_path, capsys): +class TestSetNonInteractive: + def test_positional_value_sets_default(self, tmp_path): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") - cmd_auth_default_show(config_file=config_file) + cmd_auth_default("TEST", config_file=config_file) + data = yaml.safe_load(config_file.read_text()) + assert data["GENERAL"]["default_config"] == "TEST" + + def test_unknown_env_errors_and_leaves_file_unchanged(self, tmp_path, capsys): + config_file = tmp_path / "config.yml" + _write_config(config_file, default="PROD") + before = config_file.read_text() + cmd_auth_default("NOPE", config_file=config_file) output = capsys.readouterr().out - assert "PROD" in output - assert "TEST" in output - assert "(default)" in output - # The default env is marked prominently with an arrow. - assert "→ PROD" in output + assert "NOPE" in output + assert config_file.read_text() == before def test_missing_config_file(self, tmp_path, capsys): config_file = tmp_path / "nonexistent.yml" - cmd_auth_default_show(config_file=config_file) + cmd_auth_default("PROD", config_file=config_file) assert "not found" in capsys.readouterr().out -class TestCmdAuthDefaultSet: - def test_sets_default_non_interactively(self, tmp_path): +class TestInteractivePicker: + def test_selects_default_via_picker(self, tmp_path, mocker): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") - cmd_auth_default_set("TEST", config_file=config_file) + # The picker runs only when no value is passed; it returns the chosen environment. + picker = mocker.patch("bfabric_scripts.cli.login.default_config.resolve_choice", return_value="TEST") + cmd_auth_default(config_file=config_file) data = yaml.safe_load(config_file.read_text()) assert data["GENERAL"]["default_config"] == "TEST" + # The current default is pre-selected in the picker. + assert picker.call_args.kwargs["default"] == "PROD" - def test_prompts_when_env_omitted(self, tmp_path, mocker, capsys): + def test_cancelled_picker_leaves_file_unchanged(self, tmp_path, mocker, capsys): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") - # The picker shows a numbered menu and asks for a number; env names are sorted - # (PROD=1, TEST=2), so choosing "2" selects TEST. - mocker.patch("bfabric_scripts.cli.login.default_config.Prompt.ask", return_value="2") - cmd_auth_default_set(config_file=config_file) - data = yaml.safe_load(config_file.read_text()) - assert data["GENERAL"]["default_config"] == "TEST" - # The menu is printed with each environment on its own numbered line. - menu = capsys.readouterr().out - assert "1. PROD" in menu - assert "2. TEST" in menu + before = config_file.read_text() + mocker.patch("bfabric_scripts.cli.login.default_config.resolve_choice", return_value=None) + mocker.patch("bfabric_scripts.cli.login.default_config.is_interactive", return_value=True) + cmd_auth_default(config_file=config_file) + assert "No changes made" in capsys.readouterr().out + assert config_file.read_text() == before - def test_unknown_env_errors_and_leaves_file_unchanged(self, tmp_path, capsys): + +class TestNonInteractiveListing: + def test_lists_environments_when_no_tty_and_no_value(self, tmp_path, mocker, capsys): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") before = config_file.read_text() - cmd_auth_default_set("NOPE", config_file=config_file) + mocker.patch("bfabric_scripts.cli.login.default_config.resolve_choice", return_value=None) + mocker.patch("bfabric_scripts.cli.login.default_config.is_interactive", return_value=False) + cmd_auth_default(config_file=config_file) output = capsys.readouterr().out - assert "NOPE" in output + assert "PROD" in output + assert "TEST" in output + # The current default is still marked prominently. + assert "→ PROD" in output + # Each row shows the host and auth method so the environments are distinguishable. + assert "prod.example.com" in output + assert "oauth" in output + assert "test.example.com" in output + assert "pat" in output assert config_file.read_text() == before - def test_missing_config_file(self, tmp_path, capsys): - config_file = tmp_path / "nonexistent.yml" - cmd_auth_default_set("PROD", config_file=config_file) - assert "not found" in capsys.readouterr().out + def test_empty_config_reports_no_environments(self, tmp_path, capsys): + config_file = tmp_path / "config.yml" + config_file.write_text(yaml.dump({"GENERAL": {}})) + cmd_auth_default(config_file=config_file) + assert "No environments configured" in capsys.readouterr().out diff --git a/tests/bfabric_scripts/cli/test_interactive.py b/tests/bfabric_scripts/cli/test_interactive.py new file mode 100644 index 00000000..39d28066 --- /dev/null +++ b/tests/bfabric_scripts/cli/test_interactive.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import questionary + +from bfabric_scripts.cli.interactive import resolve_choice, select_choice, select_or_input + + +class TestResolveChoice: + def test_returns_explicit_value_verbatim(self, mocker): + # An explicit value short-circuits: no interactivity check, no prompt. + select = mocker.patch("bfabric_scripts.cli.interactive.select_choice") + assert resolve_choice("TEST", ["PROD", "TEST"], message="Pick") == "TEST" + select.assert_not_called() + + def test_returns_none_when_not_interactive(self, mocker): + mocker.patch("bfabric_scripts.cli.interactive.is_interactive", return_value=False) + assert resolve_choice(None, ["PROD", "TEST"], message="Pick") is None + + def test_interactive_uses_select_choice(self, mocker): + mocker.patch("bfabric_scripts.cli.interactive.is_interactive", return_value=True) + select = mocker.patch("bfabric_scripts.cli.interactive.select_choice", return_value="PROD") + assert resolve_choice(None, ["PROD", "TEST"], message="Pick", default="PROD") == "PROD" + select.assert_called_once_with("Pick", ["PROD", "TEST"], default="PROD", describe=None, search=False) + + def test_allow_new_uses_select_or_input(self, mocker): + mocker.patch("bfabric_scripts.cli.interactive.is_interactive", return_value=True) + prompt = mocker.patch("bfabric_scripts.cli.interactive.select_or_input", return_value="NEW") + assert resolve_choice(None, ["PROD"], message="Pick", allow_new=True) == "NEW" + prompt.assert_called_once_with("Pick", ["PROD"], default=None) + + +class TestSelectChoice: + def test_delegates_to_questionary_select(self, mocker): + question = mocker.MagicMock() + question.ask.return_value = "TEST" + select = mocker.patch("bfabric_scripts.cli.interactive.questionary.select", return_value=question) + assert select_choice("Pick", ["PROD", "TEST"], default="TEST") == "TEST" + select.assert_called_once_with( + "Pick", choices=["PROD", "TEST"], default="TEST", use_search_filter=False, use_jk_keys=True + ) + + def test_describe_builds_labelled_choices_but_returns_plain_value(self, mocker): + question = mocker.MagicMock() + question.ask.return_value = "PROD" + select = mocker.patch("bfabric_scripts.cli.interactive.questionary.select", return_value=question) + assert select_choice("Pick", ["PROD", "TEST"], describe=lambda c: f"{c} info") == "PROD" + passed = select.call_args.kwargs["choices"] + assert all(isinstance(choice, questionary.Choice) for choice in passed) + assert [choice.title for choice in passed] == ["PROD info", "TEST info"] + assert [choice.value for choice in passed] == ["PROD", "TEST"] + + def test_search_flag_enables_live_filter(self, mocker): + question = mocker.MagicMock() + question.ask.return_value = "PROD" + select = mocker.patch("bfabric_scripts.cli.interactive.questionary.select", return_value=question) + select_choice("Pick", ["PROD", "TEST"], search=True) + assert select.call_args.kwargs["use_search_filter"] is True + # j/k must be released as navigation keys so they can be typed into the filter. + assert select.call_args.kwargs["use_jk_keys"] is False + + +class TestSelectOrInput: + def test_maps_empty_answer_to_none(self, mocker): + question = mocker.MagicMock() + question.ask.return_value = "" + mocker.patch("bfabric_scripts.cli.interactive.questionary.autocomplete", return_value=question) + assert select_or_input("Pick", ["PROD"]) is None + + def test_returns_typed_value(self, mocker): + question = mocker.MagicMock() + question.ask.return_value = "NEW" + autocomplete = mocker.patch("bfabric_scripts.cli.interactive.questionary.autocomplete", return_value=question) + assert select_or_input("Pick", ["PROD"], default="X") == "NEW" + autocomplete.assert_called_once_with("Pick", choices=["PROD"], default="X") From 529d4623abdbaf4fa4db94677e2cb108c894c6ca Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Wed, 15 Jul 2026 10:48:11 +0200 Subject: [PATCH 02/29] feat(bfabric_scripts): interactive env picker + named scope presets for auth login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `auth pkce` / `auth device-code` / `auth pat` commands no longer hardcode `config_env="PRODUCTION"`. When `--config-env` is omitted they prompt to pick an existing environment or type a new name (prefilled with the current default, Tab to autocomplete); non-interactively they target the current default env, else PRODUCTION. `auth pkce` / `auth device-code` gain named `--scope` presets — `read-only`, `read-write`, `read-write-upload` (adds `tus`) — that expand to the real scope string; a raw scope string still passes through. Omitting `--scope` opens a picker of the presets plus a Custom option that prompts for raw scopes. Shared resolution and the presets live in cli/login/_common.py so the three commands don't duplicate them; cli.interactive gains a text_input helper and a Tab-autocomplete hint on select_or_input. --- bfabric_scripts/docs/changelog.md | 2 +- .../src/bfabric_scripts/cli/interactive.py | 16 +++- .../src/bfabric_scripts/cli/login/_common.py | 95 +++++++++++++++++++ .../bfabric_scripts/cli/login/device_code.py | 21 +++- .../src/bfabric_scripts/cli/login/pat.py | 10 +- .../src/bfabric_scripts/cli/login/pkce.py | 21 +++- .../cli/login/test_cmd_login_device_code.py | 23 +++++ .../cli/login/test_cmd_login_pkce.py | 52 ++++++++++ .../cli/login/test_login_common.py | 95 +++++++++++++++++++ tests/bfabric_scripts/cli/test_interactive.py | 29 +++++- 10 files changed, 352 insertions(+), 12 deletions(-) create mode 100644 bfabric_scripts/src/bfabric_scripts/cli/login/_common.py create mode 100644 tests/bfabric_scripts/cli/login/test_login_common.py diff --git a/bfabric_scripts/docs/changelog.md b/bfabric_scripts/docs/changelog.md index 7309182d..47671829 100644 --- a/bfabric_scripts/docs/changelog.md +++ b/bfabric_scripts/docs/changelog.md @@ -13,7 +13,7 @@ Versioning currently follows `X.Y.Z` semantic versioning, independent of the `bf ## \[1.16.0rc2\] - 2026-07-15 - `bfabric-cli auth` command group for OAuth authentication and client management: - - Login: `auth pkce` (browser), `auth device-code` (headless), `auth pat` (Personal Access Token). + - Login: `auth pkce` (browser), `auth device-code` (headless), `auth pat` (Personal Access Token). When `--config-env` is omitted the command prompts for the environment (pick an existing one or type a new name, prefilled with the current default); non-interactively it targets the current default env, else `PRODUCTION`. `auth pkce` / `auth device-code` accept `--scope` as a named preset (`read-only` / `read-write` / `read-write-upload`, the last adding the `tus` upload scope) or a raw scope string, with an interactive picker (plus a Custom option) when omitted. - `auth register` / `auth register-webapp` — dynamic client registration, optionally with a linked B-Fabric app. - `auth default [CONFIG_ENV]` — set the default environment; an arrow-key interactive picker (navigate the list or type to filter, Enter to select; each row shows the host and auth method) opens when no value is given, or lists the environments in a non-interactive context. - `auth status` and `auth logout`. diff --git a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py index 36590c17..c8125e6d 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py @@ -57,8 +57,22 @@ def select_or_input(message: str, choices: Sequence[str], *, default: str | None Returns the entered value, or ``None`` if the user cancels or submits an empty answer. """ + items = list(choices) + # Autocomplete only reveals suggestions on Tab, which isn't discoverable -- hint it, but only + # when there actually are suggestions to complete (a first-time prompt with no choices wouldn't). + prompt = f"{message} (Tab to autocomplete)" if items else message # questionary's ``ask()`` is typed ``Any``; the autocomplete prompt yields the text or None. - answer = cast("str | None", questionary.autocomplete(message, choices=list(choices), default=default or "").ask()) + answer = cast("str | None", questionary.autocomplete(prompt, choices=items, default=default or "").ask()) + return answer or None + + +def text_input(message: str, *, default: str = "") -> str | None: + """Prompt for a free-text value, prefilled with *default*. + + Returns the entered text, or ``None`` if the user cancels or submits an empty answer. + """ + # questionary's ``ask()`` is typed ``Any``; the text prompt yields the entered string or None. + answer = cast("str | None", questionary.text(message, default=default).ask()) return answer or None diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py new file mode 100644 index 00000000..4300ef67 --- /dev/null +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py @@ -0,0 +1,95 @@ +"""Shared parameter resolution for the ``auth`` login commands. + +Both the environment name and the OAuth scope may be given on the command line, picked +interactively, or (for the environment) typed as a new value. This centralizes that logic so +``pkce`` / ``device_code`` / ``pat`` don't each re-implement it. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE +from bfabric.config.config_file import ConfigFile +from bfabric_scripts.cli.interactive import is_interactive, resolve_choice, select_choice, text_input + +# Named OAuth scope sets. Derived from DEFAULT_OAUTH_SCOPE so the "read-write" baseline and the +# upload variant can't drift from the core default; only the read-only set drops ``api:write``. +_SCOPE_PRESETS: dict[str, str] = { + "read-only": "api:read openid profile email groups", + "read-write": DEFAULT_OAUTH_SCOPE, + "read-write-upload": f"{DEFAULT_OAUTH_SCOPE} tus", +} +_SCOPE_LABELS: dict[str, str] = { + "read-only": "read-only api", + "read-write": "read-write api", + "read-write-upload": "read-write api + upload (tus)", +} +_DEFAULT_SCOPE_PRESET = "read-write" +# Interactive-only sentinel: choosing it opens a free-text prompt for raw scopes. +_CUSTOM = "custom" + +# Fallback environment name when none is configured and none is given (historical default). +_FALLBACK_ENV = "PRODUCTION" + + +def _existing_environments(config_file: Path) -> tuple[list[str], str | None]: + """Return the configured environment names and the current default (``[]``, ``None`` if absent).""" + config_path = Path(config_file).expanduser() + if not config_path.is_file(): + return [], None + config_file_obj = ConfigFile.model_validate(yaml.safe_load(config_path.read_text())) + return list(config_file_obj.environments), config_file_obj.general.default_config + + +def resolve_config_env(config_env: str | None, config_file: Path) -> str | None: + """Resolve the target environment name. + + * *config_env* given -> use it verbatim. + * no TTY -> the current default environment if set, else ``"PRODUCTION"``. + * otherwise -> interactive picker: choose an existing environment or type a new name, + prefilled with that same fallback. Returns ``None`` if the user cancels. + """ + if config_env is not None: + return config_env + names, current = _existing_environments(config_file) + fallback = current or _FALLBACK_ENV + if not is_interactive(): + return fallback + return resolve_choice(None, names, message="Environment name", allow_new=True, default=fallback) + + +def _scope_menu_label(choice: str) -> str: + """Menu label for a scope preset (name + the scopes it maps to) or the Custom entry.""" + width = max(len(label) for label in _SCOPE_LABELS.values()) + if choice == _CUSTOM: + return f"{'Custom…'.ljust(width)} (enter scopes manually)" + return f"{_SCOPE_LABELS[choice].ljust(width)} {_SCOPE_PRESETS[choice]}" + + +def resolve_scope(scope: str | None) -> str | None: + """Resolve the OAuth scope string. + + * *scope* given -> a preset name expands to its scope string; anything else passes through + as a raw space-separated scope string. + * no TTY -> the ``read-write`` default (``DEFAULT_OAUTH_SCOPE``). + * otherwise -> interactive picker of the named presets plus a Custom option that opens a + free-text prompt. Returns ``None`` if the user cancels. + """ + if scope is not None: + return _SCOPE_PRESETS.get(scope, scope) + if not is_interactive(): + return DEFAULT_OAUTH_SCOPE + picked = select_choice( + "Select OAuth scope set", + [*_SCOPE_PRESETS, _CUSTOM], + default=_DEFAULT_SCOPE_PRESET, + describe=_scope_menu_label, + ) + if picked is None: + return None + if picked == _CUSTOM: + return text_input("Enter OAuth scopes (space-separated)", default=DEFAULT_OAUTH_SCOPE) + return _SCOPE_PRESETS[picked] diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py index fa8412ad..6d8ded6c 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py @@ -7,21 +7,30 @@ import cyclopts -from bfabric._oauth._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE +from bfabric._oauth._constants import DEFAULT_CLIENT_ID from bfabric._oauth.credential_provider import OAuthCredentialProvider from bfabric._oauth.device_code import device_code_login from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config +from bfabric_scripts.cli.login._common import resolve_config_env, resolve_scope def cmd_login_device_code( base_url: Annotated[str, cyclopts.Parameter(help="B-Fabric instance URL.")], *, client_id: Annotated[str, cyclopts.Parameter(help="OAuth client ID.")] = DEFAULT_CLIENT_ID, - config_env: Annotated[str, cyclopts.Parameter(help="Environment name in the config file.")] = "PRODUCTION", + config_env: Annotated[ + str | None, cyclopts.Parameter(help="Environment name (interactive picker: existing or new, if omitted).") + ] = None, config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_OAUTH_SCOPE, + scope: Annotated[ + str | None, + cyclopts.Parameter( + help="OAuth scope preset (read-only | read-write | read-write-upload) or a raw scope string " + "(interactive picker if omitted)." + ), + ] = None, timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for authorization.")] = 600.0, set_default: Annotated[ bool, cyclopts.Parameter(help="Set this environment as the default in the config file.") @@ -30,6 +39,12 @@ def cmd_login_device_code( """Authenticate via device code flow (for headless environments).""" import sys + config_env = resolve_config_env(config_env, config_file) + scope = resolve_scope(scope) + if config_env is None or scope is None: + print("Login aborted.", file=sys.stderr) + return + base_url = base_url.rstrip("/") try: token = device_code_login( diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py b/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py index 91e2329e..25e223b2 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py @@ -11,19 +11,27 @@ from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config +from bfabric_scripts.cli.login._common import resolve_config_env def cmd_login_pat( base_url: Annotated[str, cyclopts.Parameter(help="B-Fabric instance URL.")], *, pat: Annotated[str | None, cyclopts.Parameter(help="Personal Access Token (prompted if omitted).")] = None, - config_env: Annotated[str, cyclopts.Parameter(help="Environment name in the config file.")] = "PRODUCTION", + config_env: Annotated[ + str | None, cyclopts.Parameter(help="Environment name (interactive picker: existing or new, if omitted).") + ] = None, config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, set_default: Annotated[ bool, cyclopts.Parameter(help="Set this environment as the default in the config file.") ] = True, ) -> None: """Authenticate with a Personal Access Token (PAT).""" + config_env = resolve_config_env(config_env, config_file) + if config_env is None: + print("Login aborted.", file=sys.stderr) + return + if pat is None: pat = getpass.getpass("Personal Access Token: ") else: diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py index 46cd849a..4c33392e 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py @@ -7,21 +7,30 @@ import cyclopts -from bfabric._oauth._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE +from bfabric._oauth._constants import DEFAULT_CLIENT_ID from bfabric._oauth.credential_provider import OAuthCredentialProvider from bfabric._oauth.pkce import pkce_login from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config +from bfabric_scripts.cli.login._common import resolve_config_env, resolve_scope def cmd_login_pkce( base_url: Annotated[str, cyclopts.Parameter(help="B-Fabric instance URL.")], *, client_id: Annotated[str, cyclopts.Parameter(help="OAuth client ID.")] = DEFAULT_CLIENT_ID, - config_env: Annotated[str, cyclopts.Parameter(help="Environment name in the config file.")] = "PRODUCTION", + config_env: Annotated[ + str | None, cyclopts.Parameter(help="Environment name (interactive picker: existing or new, if omitted).") + ] = None, config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_OAUTH_SCOPE, + scope: Annotated[ + str | None, + cyclopts.Parameter( + help="OAuth scope preset (read-only | read-write | read-write-upload) or a raw scope string " + "(interactive picker if omitted)." + ), + ] = None, port: Annotated[int, cyclopts.Parameter(help="Local port for callback (0 = auto).")] = 0, timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for login.")] = 120.0, set_default: Annotated[ @@ -31,6 +40,12 @@ def cmd_login_pkce( """Authenticate via browser-based PKCE flow.""" import sys + config_env = resolve_config_env(config_env, config_file) + scope = resolve_scope(scope) + if config_env is None or scope is None: + print("Login aborted.", file=sys.stderr) + return + base_url = base_url.rstrip("/") print("Opening browser for authentication...", file=sys.stderr) print("Waiting for login to complete...", file=sys.stderr) diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py b/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py index b389131f..5b2f3170 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py @@ -4,6 +4,7 @@ import yaml +from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE from bfabric_scripts.cli.login.device_code import cmd_login_device_code @@ -61,3 +62,25 @@ def test_set_default_false_does_not_set_default(self, tmp_path, mocker): data = yaml.safe_load(config_file.read_text()) assert "default_config" not in data["GENERAL"] assert data["PROD"]["auth_method"] == "oauth" + + def test_scope_preset_is_expanded(self, tmp_path, mocker): + config_file = tmp_path / "config.yml" + token = { + "access_token": "jwt123", + "refresh_token": "rt456", + "token_type": "Bearer", + "expires_at": time.time() + 3600, + } + mock_session = mocker.MagicMock() + mock_session.token = None + mock_session.metadata = {"token_endpoint": "https://example.com/bfabric/rest/oauth/token"} + mock_dc = mocker.patch("bfabric_scripts.cli.login.device_code.device_code_login", return_value=token) + mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) + cmd_login_device_code( + base_url="https://example.com/bfabric", + client_id="test-client", + config_env="PROD", + config_file=config_file, + scope="read-write-upload", + ) + assert mock_dc.call_args.kwargs["scope"] == f"{DEFAULT_OAUTH_SCOPE} tus" diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py b/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py index 3ebebadd..4bd84c74 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py @@ -4,6 +4,7 @@ import yaml +from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE from bfabric_scripts.cli.login.pkce import cmd_login_pkce @@ -61,3 +62,54 @@ def test_set_default_false_does_not_set_default(self, tmp_path, mocker): data = yaml.safe_load(config_file.read_text()) assert "default_config" not in data["GENERAL"] assert data["PROD"]["auth_method"] == "oauth" + + def test_scope_preset_is_expanded(self, tmp_path, mocker): + config_file = tmp_path / "config.yml" + token = { + "access_token": "jwt123", + "refresh_token": "rt456", + "token_type": "Bearer", + "expires_at": time.time() + 3600, + } + mock_session = mocker.MagicMock() + mock_session.token = None + mock_session.metadata = {"token_endpoint": "https://example.com/bfabric/rest/oauth/token"} + mock_pkce = mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=token) + mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) + cmd_login_pkce( + base_url="https://example.com/bfabric", + client_id="test-client", + config_env="PROD", + config_file=config_file, + scope="read-write-upload", + ) + # The preset name expands to the real scope string requested from the OAuth flow. + assert mock_pkce.call_args.kwargs["scope"] == f"{DEFAULT_OAUTH_SCOPE} tus" + + def test_config_env_omitted_falls_back_to_current_default(self, tmp_path, mocker): + config_file = tmp_path / "config.yml" + config_file.write_text( + yaml.dump( + { + "GENERAL": {"default_config": "EXISTING"}, + "EXISTING": {"base_url": "https://old.example.com", "auth_method": "oauth"}, + } + ) + ) + token = { + "access_token": "jwt123", + "refresh_token": "rt456", + "token_type": "Bearer", + "expires_at": time.time() + 3600, + } + mock_session = mocker.MagicMock() + mock_session.token = None + mock_session.metadata = {"token_endpoint": "https://example.com/bfabric/rest/oauth/token"} + mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=token) + mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) + # No TTY (pytest) and no --config-env => reuse the current default env, not PRODUCTION. + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=False) + cmd_login_pkce(base_url="https://example.com/bfabric", client_id="c", config_file=config_file) + data = yaml.safe_load(config_file.read_text()) + assert data["EXISTING"]["base_url"] == "https://example.com/bfabric" + assert "PRODUCTION" not in data diff --git a/tests/bfabric_scripts/cli/login/test_login_common.py b/tests/bfabric_scripts/cli/login/test_login_common.py new file mode 100644 index 00000000..44656892 --- /dev/null +++ b/tests/bfabric_scripts/cli/login/test_login_common.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import yaml + +from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE +from bfabric_scripts.cli.login._common import resolve_config_env, resolve_scope + + +def _write_config(config_file, default="PROD"): + general = {"default_config": default} if default is not None else {} + config_file.write_text( + yaml.dump( + { + "GENERAL": general, + "PROD": {"base_url": "https://prod.example.com", "auth_method": "oauth"}, + "TEST": {"base_url": "https://test.example.com", "auth_method": "oauth"}, + } + ) + ) + + +class TestResolveConfigEnv: + def test_explicit_value_returned_as_is(self, tmp_path, mocker): + # An explicit value short-circuits: no file read, no prompt. + resolve = mocker.patch("bfabric_scripts.cli.login._common.resolve_choice") + assert resolve_config_env("STAGE", tmp_path / "missing.yml") == "STAGE" + resolve.assert_not_called() + + def test_non_interactive_uses_current_default(self, tmp_path, mocker): + config_file = tmp_path / "config.yml" + _write_config(config_file, default="TEST") + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=False) + assert resolve_config_env(None, config_file) == "TEST" + + def test_non_interactive_without_default_falls_back_to_production(self, tmp_path, mocker): + config_file = tmp_path / "config.yml" + _write_config(config_file, default=None) + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=False) + assert resolve_config_env(None, config_file) == "PRODUCTION" + + def test_non_interactive_missing_file_falls_back_to_production(self, tmp_path, mocker): + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=False) + assert resolve_config_env(None, tmp_path / "missing.yml") == "PRODUCTION" + + def test_interactive_offers_existing_and_allows_new(self, tmp_path, mocker): + config_file = tmp_path / "config.yml" + _write_config(config_file, default="PROD") + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) + resolve = mocker.patch("bfabric_scripts.cli.login._common.resolve_choice", return_value="NEWENV") + assert resolve_config_env(None, config_file) == "NEWENV" + args = resolve.call_args.args + kwargs = resolve.call_args.kwargs + assert args[0] is None + assert set(args[1]) == {"PROD", "TEST"} + assert kwargs["allow_new"] is True + # The current default is prefilled. + assert kwargs["default"] == "PROD" + + def test_interactive_cancel_returns_none(self, tmp_path, mocker): + config_file = tmp_path / "config.yml" + _write_config(config_file, default="PROD") + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) + mocker.patch("bfabric_scripts.cli.login._common.resolve_choice", return_value=None) + assert resolve_config_env(None, config_file) is None + + +class TestResolveScope: + def test_preset_slug_expands(self): + assert resolve_scope("read-only") == "api:read openid profile email groups" + assert resolve_scope("read-write") == DEFAULT_OAUTH_SCOPE + assert resolve_scope("read-write-upload") == f"{DEFAULT_OAUTH_SCOPE} tus" + + def test_raw_string_passthrough(self): + assert resolve_scope("api:read custom:thing") == "api:read custom:thing" + + def test_non_interactive_defaults_to_read_write(self, mocker): + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=False) + assert resolve_scope(None) == DEFAULT_OAUTH_SCOPE + + def test_interactive_preset_pick_expands(self, mocker): + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) + mocker.patch("bfabric_scripts.cli.login._common.select_choice", return_value="read-write-upload") + assert resolve_scope(None) == f"{DEFAULT_OAUTH_SCOPE} tus" + + def test_interactive_custom_prompts_for_raw_scopes(self, mocker): + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) + mocker.patch("bfabric_scripts.cli.login._common.select_choice", return_value="custom") + text = mocker.patch("bfabric_scripts.cli.login._common.text_input", return_value="api:read containers") + assert resolve_scope(None) == "api:read containers" + text.assert_called_once() + + def test_interactive_cancel_returns_none(self, mocker): + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) + mocker.patch("bfabric_scripts.cli.login._common.select_choice", return_value=None) + assert resolve_scope(None) is None diff --git a/tests/bfabric_scripts/cli/test_interactive.py b/tests/bfabric_scripts/cli/test_interactive.py index 39d28066..a21de65d 100644 --- a/tests/bfabric_scripts/cli/test_interactive.py +++ b/tests/bfabric_scripts/cli/test_interactive.py @@ -2,7 +2,7 @@ import questionary -from bfabric_scripts.cli.interactive import resolve_choice, select_choice, select_or_input +from bfabric_scripts.cli.interactive import resolve_choice, select_choice, select_or_input, text_input class TestResolveChoice: @@ -66,9 +66,32 @@ def test_maps_empty_answer_to_none(self, mocker): mocker.patch("bfabric_scripts.cli.interactive.questionary.autocomplete", return_value=question) assert select_or_input("Pick", ["PROD"]) is None - def test_returns_typed_value(self, mocker): + def test_returns_typed_value_and_hints_tab_when_choices_present(self, mocker): question = mocker.MagicMock() question.ask.return_value = "NEW" autocomplete = mocker.patch("bfabric_scripts.cli.interactive.questionary.autocomplete", return_value=question) assert select_or_input("Pick", ["PROD"], default="X") == "NEW" - autocomplete.assert_called_once_with("Pick", choices=["PROD"], default="X") + # The Tab-autocomplete hint is appended when there are suggestions to complete. + autocomplete.assert_called_once_with("Pick (Tab to autocomplete)", choices=["PROD"], default="X") + + def test_no_hint_when_no_choices(self, mocker): + question = mocker.MagicMock() + question.ask.return_value = "NEW" + autocomplete = mocker.patch("bfabric_scripts.cli.interactive.questionary.autocomplete", return_value=question) + assert select_or_input("Pick", []) == "NEW" + autocomplete.assert_called_once_with("Pick", choices=[], default="") + + +class TestTextInput: + def test_returns_entered_value(self, mocker): + question = mocker.MagicMock() + question.ask.return_value = "api:read foo" + text = mocker.patch("bfabric_scripts.cli.interactive.questionary.text", return_value=question) + assert text_input("Scopes", default="api:read") == "api:read foo" + text.assert_called_once_with("Scopes", default="api:read") + + def test_maps_empty_answer_to_none(self, mocker): + question = mocker.MagicMock() + question.ask.return_value = "" + mocker.patch("bfabric_scripts.cli.interactive.questionary.text", return_value=question) + assert text_input("Scopes") is None From 895ed4302f995ca855ad4c90abeb1f77867a1bf2 Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Wed, 15 Jul 2026 11:17:20 +0200 Subject: [PATCH 03/29] feat(bfabric_scripts): round out auth command group (list, richer status, logout removal, set-default prompt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth list: list configured environments (host + auth method), marking the default - auth status: for OAuth, report cached-token freshness and the granted scope, annotated with the matching named preset - auth logout: rework into "remove environment" — interactive picker (preselecting the current default), deletes the config entry and any cached OAuth tokens, guarded by a confirmation prompt (--no-confirm to skip; required to remove non-interactively) - login (pkce/device-code/pat): unless --set-default/--no-set-default is given, ask (preselected yes) whether the new environment should become the config default - consolidate the shared auth-group rendering/resolution into cli/login/_common.py; add cli.interactive.confirm and config_writer.remove_environment_from_config (also clears a dangling default so the file stays loadable) --- bfabric/src/bfabric/config/config_writer.py | 42 ++++- bfabric_scripts/docs/changelog.md | 7 +- .../src/bfabric_scripts/cli/cli_auth.py | 2 + .../src/bfabric_scripts/cli/interactive.py | 11 ++ .../src/bfabric_scripts/cli/login/_common.py | 121 ++++++++++++++- .../cli/login/default_config.py | 53 +------ .../bfabric_scripts/cli/login/device_code.py | 8 +- .../src/bfabric_scripts/cli/login/list.py | 32 ++++ .../src/bfabric_scripts/cli/login/logout.py | 74 +++++++-- .../src/bfabric_scripts/cli/login/pat.py | 8 +- .../src/bfabric_scripts/cli/login/pkce.py | 8 +- .../src/bfabric_scripts/cli/login/status.py | 12 +- tests/bfabric/config/test_config_writer.py | 64 +++++++- .../cli/login/test_cmd_auth_list.py | 41 +++++ .../cli/login/test_cmd_login_logout.py | 143 ++++++++++++++---- .../cli/login/test_cmd_login_pkce.py | 29 ++++ .../cli/login/test_cmd_login_status.py | 50 ++++++ .../cli/login/test_login_common.py | 68 ++++++++- tests/bfabric_scripts/cli/test_interactive.py | 24 ++- 19 files changed, 674 insertions(+), 123 deletions(-) create mode 100644 bfabric_scripts/src/bfabric_scripts/cli/login/list.py create mode 100644 tests/bfabric_scripts/cli/login/test_cmd_auth_list.py diff --git a/bfabric/src/bfabric/config/config_writer.py b/bfabric/src/bfabric/config/config_writer.py index a12a194b..9e6e3c52 100644 --- a/bfabric/src/bfabric/config/config_writer.py +++ b/bfabric/src/bfabric/config/config_writer.py @@ -9,7 +9,7 @@ import copy import os from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import yaml @@ -144,3 +144,43 @@ def set_default_config(config_path: Path, env_name: str) -> None: general["default_config"] = env_name _write_config_file(config_path, existing) + + +def remove_environment_from_config(config_path: Path, env_name: str) -> None: + """Delete an environment section from the bfabricpy YAML config. + + Removes the top-level *env_name* section and, if ``GENERAL.default_config`` pointed at it, + clears that default -- otherwise the reader (:class:`ConfigFile`) would refuse to load a file + whose default names a missing environment. Other environments and general settings are + preserved. + + :param config_path: Path to the YAML config file (will be expanded). + :param env_name: Name of an existing environment to remove. + :raises FileNotFoundError: If the config file does not exist. + :raises ValueError: If *env_name* is not among the configured environments; the file is + left untouched in that case. + """ + config_path = Path(config_path).expanduser() + if not config_path.is_file(): + raise FileNotFoundError(f"Config file not found: {config_path}") + + loaded: object = yaml.safe_load(config_path.read_text()) # pyright: ignore[reportAny] + existing: dict[str, object] + existing = loaded if isinstance(loaded, dict) else {} # pyright: ignore[reportUnknownVariableType] + + # Enumerate through the reader so the membership check matches how the file loads back. + # ConfigFile's "before" validators mutate their input in place, so validate a deep copy and + # keep ``existing`` pristine for the write. + config_file_obj = ConfigFile.model_validate(copy.deepcopy(existing)) + if env_name not in config_file_obj.environments: + available = ", ".join(sorted(config_file_obj.environments)) or "(none)" + raise ValueError(f"Environment {env_name!r} is not defined. Available environments: {available}") + + _ = existing.pop(env_name, None) + general = existing.get("GENERAL") + if isinstance(general, dict): + general_map = cast("dict[str, object]", general) + if general_map.get("default_config") == env_name: + _ = general_map.pop("default_config", None) + + _write_config_file(config_path, existing) diff --git a/bfabric_scripts/docs/changelog.md b/bfabric_scripts/docs/changelog.md index 47671829..965abe02 100644 --- a/bfabric_scripts/docs/changelog.md +++ b/bfabric_scripts/docs/changelog.md @@ -13,16 +13,17 @@ Versioning currently follows `X.Y.Z` semantic versioning, independent of the `bf ## \[1.16.0rc2\] - 2026-07-15 - `bfabric-cli auth` command group for OAuth authentication and client management: - - Login: `auth pkce` (browser), `auth device-code` (headless), `auth pat` (Personal Access Token). When `--config-env` is omitted the command prompts for the environment (pick an existing one or type a new name, prefilled with the current default); non-interactively it targets the current default env, else `PRODUCTION`. `auth pkce` / `auth device-code` accept `--scope` as a named preset (`read-only` / `read-write` / `read-write-upload`, the last adding the `tus` upload scope) or a raw scope string, with an interactive picker (plus a Custom option) when omitted. + - Login: `auth pkce` (browser), `auth device-code` (headless), `auth pat` (Personal Access Token). When `--config-env` is omitted the command prompts for the environment (pick an existing one or type a new name, prefilled with the current default); non-interactively it targets the current default env, else `PRODUCTION`. `auth pkce` / `auth device-code` accept `--scope` as a named preset (`read-only` / `read-write` / `read-write-upload`, the last adding the `tus` upload scope) or a raw scope string, with an interactive picker (plus a Custom option) when omitted. Unless `--set-default` / `--no-set-default` is given, the command asks (default yes) whether the new environment should become the config default. - `auth register` / `auth register-webapp` — dynamic client registration, optionally with a linked B-Fabric app. - `auth default [CONFIG_ENV]` — set the default environment; an arrow-key interactive picker (navigate the list or type to filter, Enter to select; each row shows the host and auth method) opens when no value is given, or lists the environments in a non-interactive context. - - `auth status` and `auth logout`. + - `auth list` — list the configured environments (host + auth method), marking the default. + - `auth status` — for an OAuth environment now also reports the cached token's freshness (present / expired / expires-in) and the granted scope, annotated with the matching named preset. `auth logout [CONFIG_ENV]` removes an environment entirely (its config entry and any cached OAuth tokens): an interactive picker opens when the environment is omitted, and a confirmation prompt guards the deletion (`--no-confirm` to skip; required to remove non-interactively). - `auth pat` stores the token under a `pat` key with `auth_method: pat` (not `login: __oauth__` / `password:`), keeping the shared config parseable by older (≤1.19.0) clients; `auth status` reports these as `pat`. - `bfabric-cli workunit upload FILES...` — upload files and directories to a workunit over tus (resumable, large-file capable), creating a new workunit or targeting `--workunit-id`. One resource per file, skips duplicates (unless `--force`), expands directories, live progress (`--no-progress`); `--track-job` records an `UPLOAD` job. Requires an OAuth client with the `tus` scope. - `bfabric-cli api create` / `api update` accept `--format json|yaml|tsv|table_rich` (default `json`), matching `api read`. - `bfabric-cli dataset update` — update an existing dataset with a change preview before confirming (`csv`/`tsv`/`xlsx`/`parquet`, same validation flags as `dataset upload`). - `api create` / `api update` now emit valid JSON (was Python `repr` via `rich.pretty.pprint`, breaking `jq`) and serialise `datetime` / `Decimal` to strings instead of raising `TypeError` ([#503](https://github.com/fgcz/bfabricPy/issues/503)). -- Internal: `dataset upload` and `bfabric_save_csv2dataset.py` now use `bfabric.operations.dataset.create_dataset`; API error handling is centralized in `@use_client` (no more `@logger.catch`); `--config-env` naming unified. Declares `lxml` as an explicit dependency (was transitive via the now-optional `zeep`) and requires `bfabric[transfer]` >= 1.20 (for `upload_files` / tus). Adds `questionary` for interactive CLI prompts, wrapped in a reusable `cli.interactive` helper (`resolve_choice` / `select_choice` / `select_or_input`). +- Internal: `dataset upload` and `bfabric_save_csv2dataset.py` now use `bfabric.operations.dataset.create_dataset`; API error handling is centralized in `@use_client` (no more `@logger.catch`); `--config-env` naming unified. Declares `lxml` as an explicit dependency (was transitive via the now-optional `zeep`) and requires `bfabric[transfer]` >= 1.20 (for `upload_files` / tus). Adds `questionary` for interactive CLI prompts, wrapped in a reusable `cli.interactive` helper (`resolve_choice` / `select_choice` / `select_or_input` / `text_input` / `confirm`); adds `config_writer.remove_environment_from_config`. ## \[1.15.0\] - 2026-04-20 diff --git a/bfabric_scripts/src/bfabric_scripts/cli/cli_auth.py b/bfabric_scripts/src/bfabric_scripts/cli/cli_auth.py index 2ca19340..4f3ed799 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/cli_auth.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/cli_auth.py @@ -2,6 +2,7 @@ from bfabric_scripts.cli.login.default_config import cmd_auth_default from bfabric_scripts.cli.login.device_code import cmd_login_device_code +from bfabric_scripts.cli.login.list import cmd_auth_list from bfabric_scripts.cli.login.logout import cmd_login_logout from bfabric_scripts.cli.login.pat import cmd_login_pat from bfabric_scripts.cli.login.pkce import cmd_login_pkce @@ -18,3 +19,4 @@ _ = cmd_auth.command(cmd_login_status, name="status") _ = cmd_auth.command(cmd_login_logout, name="logout") _ = cmd_auth.command(cmd_auth_default, name="default") +_ = cmd_auth.command(cmd_auth_list, name="list") diff --git a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py index c8125e6d..a7e7e72a 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py @@ -76,6 +76,17 @@ def text_input(message: str, *, default: str = "") -> str | None: return answer or None +def confirm(message: str, *, default: bool = False) -> bool: + """Ask a yes/no question, returning the answer as a bool. + + A cancel (Ctrl-C / Esc) yields ``None`` from questionary, which is treated as ``False`` -- + the safe answer for the destructive prompts this guards. + """ + # questionary's ``ask()`` is typed ``Any``; the confirm prompt yields a bool or None. + answer = cast("bool | None", questionary.confirm(message, default=default).ask()) + return answer is True + + def resolve_choice( value: str | None, choices: Sequence[str], diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py index 4300ef67..b8317bbe 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py @@ -1,19 +1,23 @@ -"""Shared parameter resolution for the ``auth`` login commands. +"""Shared helpers for the ``auth`` command group. -Both the environment name and the OAuth scope may be given on the command line, picked -interactively, or (for the environment) typed as a new value. This centralizes that logic so -``pkce`` / ``device_code`` / ``pat`` don't each re-implement it. +Covers parameter resolution for the login commands (environment name and OAuth scope may be +given on the command line, picked interactively, or -- for the environment -- typed as a new +value) plus the rendering used by ``auth default`` / ``auth list`` / ``auth status``, so the +individual commands don't each re-implement it. """ from __future__ import annotations from pathlib import Path +from urllib.parse import urlsplit import yaml +from rich.console import Console +from rich.text import Text from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE -from bfabric.config.config_file import ConfigFile -from bfabric_scripts.cli.interactive import is_interactive, resolve_choice, select_choice, text_input +from bfabric.config.config_file import ConfigFile, EnvironmentConfig +from bfabric_scripts.cli.interactive import confirm, is_interactive, resolve_choice, select_choice, text_input # Named OAuth scope sets. Derived from DEFAULT_OAUTH_SCOPE so the "read-write" baseline and the # upload variant can't drift from the core default; only the read-only set drops ``api:write``. @@ -93,3 +97,108 @@ def resolve_scope(scope: str | None) -> str | None: if picked == _CUSTOM: return text_input("Enter OAuth scopes (space-separated)", default=DEFAULT_OAUTH_SCOPE) return _SCOPE_PRESETS[picked] + + +def resolve_set_default(set_default: bool | None, config_env: str) -> bool: + """Resolve whether the freshly-authenticated environment becomes the config default. + + * explicit ``--set-default`` / ``--no-set-default`` (i.e. not ``None``) -> honored verbatim. + * otherwise, no TTY -> ``True`` (the historical default). + * otherwise -> a yes/no prompt, preselected yes. + """ + if set_default is not None: + return set_default + if not is_interactive(): + return True + return confirm(f"Set '{config_env}' as the default environment?", default=True) + + +def _normalize_scope(scope: str) -> str: + """Order-insensitive form of a scope string for comparing against the presets.""" + return " ".join(sorted(scope.split())) + + +def describe_scope(scope: object) -> str: + """Render a granted OAuth scope for display, annotated with its preset name if it matches. + + * a scope equal to a preset (order-insensitive) -> ``" []"`` + * any other non-empty scope -> the raw string + * missing / non-string (a cache without a recorded scope) -> ``"(not recorded)"`` + """ + if not isinstance(scope, str) or not scope.strip(): + return "(not recorded)" + normalized = _normalize_scope(scope) + for slug, preset in _SCOPE_PRESETS.items(): + if _normalize_scope(preset) == normalized: + return f"{scope} [{slug}]" + return scope + + +def _format_duration(seconds: float) -> str: + """A coarse ``~1h05m`` / ``~7m`` / ``<1m`` label for a positive remaining duration.""" + minutes = int(seconds // 60) + if minutes >= 60: + hours, mins = divmod(minutes, 60) + return f"~{hours}h{mins:02d}m" + if minutes >= 1: + return f"~{minutes}m" + return "<1m" + + +def describe_token_cache(cached: dict[str, object] | None, *, now: float) -> str: + """Summarize a cached OAuth token's freshness. + + ``"missing"`` when absent; otherwise ``"present"``, extended with ``expired`` or + ``expires in ~…`` when the token carries a numeric ``expires_at`` (Unix seconds). + """ + if cached is None: + return "missing" + expires_at = cached.get("expires_at") + if not isinstance(expires_at, (int, float)): + return "present" + remaining = float(expires_at) - now + if remaining <= 0: + return "present, expired" + return f"present, expires in {_format_duration(remaining)}" + + +def auth_method_label(env: EnvironmentConfig) -> str: + """Label an environment's auth method, mirroring ``auth status``' precedence.""" + if env.auth_method in ("oauth", "pat"): + return env.auth_method + if env.auth is not None: + return "password" + return "none" + + +def environment_summary(env: EnvironmentConfig) -> str: + """A compact "host · auth-method" descriptor shown next to each environment name.""" + base_url = str(env.config.base_url) + host = urlsplit(base_url).netloc or base_url + return f"{host} · {auth_method_label(env)}" + + +def environment_line(name: str, *, summary: str, width: int, is_default: bool) -> Text: + """Render one environment row. Text (not markup) keeps names with "[" literal. + + The current default is prefixed with a bold-green arrow so it stands out in a long list; + the "host · auth-method" summary trails the (padded) name. + """ + padded = name.ljust(width) + if is_default: + # Chained appends (each returns the Text) keep the whole row in one expression. + return ( + Text("→ ", style="bold green") + .append(padded, style="bold green") + .append(f" {summary}", style="green") + .append(" (default)", style="green") + ) + return Text(" ").append(padded).append(f" {summary}", style="dim") + + +def print_environments(console: Console, environments: dict[str, EnvironmentConfig], default: str | None) -> None: + """List the configured environments with their host/auth summary, marking the default.""" + console.print("Configuration environments:") + width = max(len(name) for name in environments) + for name, env in environments.items(): + console.print(environment_line(name, summary=environment_summary(env), width=width, is_default=name == default)) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py b/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py index 86f98913..0bf60059 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py @@ -4,61 +4,16 @@ from pathlib import Path from typing import Annotated -from urllib.parse import urlsplit import cyclopts import yaml from rich.console import Console -from rich.text import Text from bfabric.config import DEFAULT_CONFIG_FILE -from bfabric.config.config_file import ConfigFile, EnvironmentConfig +from bfabric.config.config_file import ConfigFile from bfabric.config.config_writer import set_default_config from bfabric_scripts.cli.interactive import is_interactive, resolve_choice - - -def _auth_method(env: EnvironmentConfig) -> str: - """Label an environment's auth method, mirroring ``auth status``' precedence.""" - if env.auth_method in ("oauth", "pat"): - return env.auth_method - if env.auth is not None: - return "password" - return "none" - - -def _environment_summary(env: EnvironmentConfig) -> str: - """A compact "host · auth-method" descriptor shown next to each environment name.""" - base_url = str(env.config.base_url) - host = urlsplit(base_url).netloc or base_url - return f"{host} · {_auth_method(env)}" - - -def _environment_line(name: str, *, summary: str, width: int, is_default: bool) -> Text: - """Render one environment row. Text (not markup) keeps names with "[" literal. - - The current default is prefixed with a bold-green arrow so it stands out in a long list; - the "host · auth-method" summary trails the (padded) name. - """ - padded = name.ljust(width) - if is_default: - # Chained appends (each returns the Text) keep the whole row in one expression. - return ( - Text("→ ", style="bold green") - .append(padded, style="bold green") - .append(f" {summary}", style="green") - .append(" (default)", style="green") - ) - return Text(" ").append(padded).append(f" {summary}", style="dim") - - -def _print_environments(console: Console, environments: dict[str, EnvironmentConfig], default: str | None) -> None: - """List the configured environments with their host/auth summary, marking the default.""" - console.print("Configuration environments:") - width = max(len(name) for name in environments) - for name, env in environments.items(): - console.print( - _environment_line(name, summary=_environment_summary(env), width=width, is_default=name == default) - ) +from bfabric_scripts.cli.login._common import environment_summary, print_environments def cmd_auth_default( @@ -92,7 +47,7 @@ def cmd_auth_default( names, message="Select the default environment", default=default if default in names else None, - describe=lambda name: f"{name.ljust(width)} {_environment_summary(config_file_obj.environments[name])}", + describe=lambda name: f"{name.ljust(width)} {environment_summary(config_file_obj.environments[name])}", search=True, ) if config_env is None: @@ -101,7 +56,7 @@ def cmd_auth_default( if is_interactive(): console.print("No changes made.") else: - _print_environments(console, config_file_obj.environments, default) + print_environments(console, config_file_obj.environments, default) console.print("Run in an interactive terminal or pass an environment name to set the default.") return diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py index 6d8ded6c..ba07ed11 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py @@ -13,7 +13,7 @@ from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config -from bfabric_scripts.cli.login._common import resolve_config_env, resolve_scope +from bfabric_scripts.cli.login._common import resolve_config_env, resolve_scope, resolve_set_default def cmd_login_device_code( @@ -33,8 +33,9 @@ def cmd_login_device_code( ] = None, timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for authorization.")] = 600.0, set_default: Annotated[ - bool, cyclopts.Parameter(help="Set this environment as the default in the config file.") - ] = True, + bool | None, + cyclopts.Parameter(help="Set this environment as the default in the config file (prompted if omitted)."), + ] = None, ) -> None: """Authenticate via device code flow (for headless environments).""" import sys @@ -44,6 +45,7 @@ def cmd_login_device_code( if config_env is None or scope is None: print("Login aborted.", file=sys.stderr) return + set_default = resolve_set_default(set_default, config_env) base_url = base_url.rstrip("/") try: diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/list.py b/bfabric_scripts/src/bfabric_scripts/cli/login/list.py new file mode 100644 index 00000000..2975f19f --- /dev/null +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/list.py @@ -0,0 +1,32 @@ +"""Command to list the configured environments.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import cyclopts +import yaml +from rich.console import Console + +from bfabric.config import DEFAULT_CONFIG_FILE +from bfabric.config.config_file import ConfigFile +from bfabric_scripts.cli.login._common import print_environments + + +def cmd_auth_list( + *, + config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, +) -> None: + """List the configured environments, marking the default and showing each host / auth method.""" + config_path = Path(config_file).expanduser() + if not config_path.is_file(): + print(f"Config file not found: {config_path}") + return + + config_file_obj = ConfigFile.model_validate(yaml.safe_load(config_path.read_text())) + if not config_file_obj.environments: + print("No environments configured.") + return + + print_environments(Console(), config_file_obj.environments, config_file_obj.general.default_config) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py b/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py index 62607474..d2126884 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py @@ -1,8 +1,8 @@ -"""Logout command — clears token cache for an environment.""" +"""Logout command — removes an environment (config entry + any cached OAuth tokens).""" from __future__ import annotations -import os +import sys from pathlib import Path from typing import Annotated @@ -14,34 +14,80 @@ from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_file import ConfigFile from bfabric._oauth.token_cache import TokenCache, compute_token_cache_path +from bfabric.config.config_writer import remove_environment_from_config +from bfabric_scripts.cli.interactive import confirm, is_interactive, select_choice +from bfabric_scripts.cli.login._common import environment_summary def cmd_login_logout( + config_env: Annotated[ + str | None, + cyclopts.Parameter(help="Environment to remove (interactive picker if omitted)."), + ] = None, *, config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - config_env: Annotated[str | None, cyclopts.Parameter(help="Environment name (default: auto-detect).")] = None, + no_confirm: Annotated[ + bool, cyclopts.Parameter(help="Skip the confirmation prompt (required to remove non-interactively).") + ] = False, ) -> None: - """Clear cached OAuth tokens for an environment.""" + """Remove an environment: delete its config entry and clear any cached OAuth tokens. + + With no *config_env*, opens an interactive picker in a terminal. Because this deletes + credentials, a non-interactive run must name the environment (``--config-env``) and pass + ``--no-confirm`` to skip the confirmation it cannot prompt for. + """ config_path = Path(config_file).expanduser() if not config_path.is_file(): print(f"Config file not found: {config_path}") return config_file_obj = ConfigFile.model_validate(yaml.safe_load(config_path.read_text())) - resolved_env = config_env or os.environ.get("BFABRICPY_CONFIG_ENV") or config_file_obj.general.default_config - if resolved_env is None: - print("No environment specified and no default configured.") + environments = config_file_obj.environments + names = list(environments) + if not names: + print("No environments configured.") return - if resolved_env not in config_file_obj.environments: - print(f"Environment '{resolved_env}' not found in config.") + if config_env is None: + if not is_interactive(): + print("Specify --config-env to choose an environment to remove non-interactively.", file=sys.stderr) + return + width = max(len(name) for name in names) + default = config_file_obj.general.default_config + config_env = select_choice( + "Select the environment to remove", + names, + default=default if default in names else None, + describe=lambda name: f"{name.ljust(width)} {environment_summary(environments[name])}", + search=True, + ) + if config_env is None: + print("No changes made.") + return + + if config_env not in environments: + print(f"Environment '{config_env}' not found. Available environments: {', '.join(names)}") return - env = config_file_obj.environments[resolved_env] + env = environments[config_env] + if not no_confirm: + if not is_interactive(): + print( + f"Refusing to remove '{config_env}' without confirmation; pass --no-confirm to proceed.", + file=sys.stderr, + ) + return + if not confirm( + f"Remove environment '{config_env}' ({environment_summary(env)})? " + "This deletes its config entry and any cached OAuth tokens." + ): + print("No changes made.") + return + if env.auth_method == "oauth": client_id = env.client_id or DEFAULT_CLIENT_ID - cache_path = compute_token_cache_path(env.config.base_url.rstrip("/"), client_id, resolved_env).expanduser() + cache_path = compute_token_cache_path(env.config.base_url.rstrip("/"), client_id, config_env).expanduser() TokenCache(cache_path).clear() - print(f"Token cache cleared: {cache_path}") - else: - print(f"Environment '{resolved_env}' does not use OAuth; nothing to clear.") + + remove_environment_from_config(config_path, config_env) + print(f"Removed environment '{config_env}'.") diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py b/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py index 25e223b2..f5cc4ab2 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py @@ -11,7 +11,7 @@ from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config -from bfabric_scripts.cli.login._common import resolve_config_env +from bfabric_scripts.cli.login._common import resolve_config_env, resolve_set_default def cmd_login_pat( @@ -23,14 +23,16 @@ def cmd_login_pat( ] = None, config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, set_default: Annotated[ - bool, cyclopts.Parameter(help="Set this environment as the default in the config file.") - ] = True, + bool | None, + cyclopts.Parameter(help="Set this environment as the default in the config file (prompted if omitted)."), + ] = None, ) -> None: """Authenticate with a Personal Access Token (PAT).""" config_env = resolve_config_env(config_env, config_file) if config_env is None: print("Login aborted.", file=sys.stderr) return + set_default = resolve_set_default(set_default, config_env) if pat is None: pat = getpass.getpass("Personal Access Token: ") diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py index 4c33392e..f04d2fb7 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py @@ -13,7 +13,7 @@ from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config -from bfabric_scripts.cli.login._common import resolve_config_env, resolve_scope +from bfabric_scripts.cli.login._common import resolve_config_env, resolve_scope, resolve_set_default def cmd_login_pkce( @@ -34,8 +34,9 @@ def cmd_login_pkce( port: Annotated[int, cyclopts.Parameter(help="Local port for callback (0 = auto).")] = 0, timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for login.")] = 120.0, set_default: Annotated[ - bool, cyclopts.Parameter(help="Set this environment as the default in the config file.") - ] = True, + bool | None, + cyclopts.Parameter(help="Set this environment as the default in the config file (prompted if omitted)."), + ] = None, ) -> None: """Authenticate via browser-based PKCE flow.""" import sys @@ -45,6 +46,7 @@ def cmd_login_pkce( if config_env is None or scope is None: print("Login aborted.", file=sys.stderr) return + set_default = resolve_set_default(set_default, config_env) base_url = base_url.rstrip("/") print("Opening browser for authentication...", file=sys.stderr) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/status.py b/bfabric_scripts/src/bfabric_scripts/cli/login/status.py index c5d8588d..eb033084 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/status.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/status.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import time from pathlib import Path from typing import Annotated @@ -14,6 +15,7 @@ from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_file import ConfigFile from bfabric._oauth.token_cache import TokenCache, compute_token_cache_path +from bfabric_scripts.cli.login._common import describe_scope, describe_token_cache def cmd_login_status( @@ -43,19 +45,17 @@ def cmd_login_status( if env.auth_method == "oauth": client_id = env.client_id or DEFAULT_CLIENT_ID - print(f"Auth method: oauth") + print("Auth method: oauth") print(f"Client ID: {client_id}") cache_path = compute_token_cache_path(env.config.base_url.rstrip("/"), client_id, resolved_env).expanduser() cached = TokenCache(cache_path).load() - if cached: - print(f"Token cache: {cache_path} (present)") - else: - print(f"Token cache: {cache_path} (missing)") + print(f"Token cache: {cache_path} ({describe_token_cache(cached, now=time.time())})") + print(f"Scope: {describe_scope(cached.get('scope') if cached else None)}") elif env.auth_method == "pat": print("Auth method: pat") print("Token: stored in config file") elif env.auth is not None: - print(f"Auth method: password") + print("Auth method: password") print(f"Login: {env.auth.login}") else: print("Auth method: none") diff --git a/tests/bfabric/config/test_config_writer.py b/tests/bfabric/config/test_config_writer.py index 1139017f..62ce1736 100644 --- a/tests/bfabric/config/test_config_writer.py +++ b/tests/bfabric/config/test_config_writer.py @@ -8,7 +8,11 @@ from bfabric.config.bfabric_auth import OAUTH_LOGIN from bfabric.config.config_file import ConfigFile -from bfabric.config.config_writer import set_default_config, write_environment_to_config +from bfabric.config.config_writer import ( + remove_environment_from_config, + set_default_config, + write_environment_to_config, +) class TestWriteEnvironmentToConfig: @@ -193,3 +197,61 @@ def test_tightens_permissions(self, tmp_path): set_default_config(config_path, "TEST") mode = stat.S_IMODE(os.stat(config_path).st_mode) assert mode == 0o600 + + +class TestRemoveEnvironmentFromConfig: + @staticmethod + def _write_two_env_config(config_path, default="PROD"): + config_path.write_text( + yaml.dump( + { + "GENERAL": {"default_config": default}, + "PROD": {"base_url": "https://prod.example.com"}, + "TEST": {"base_url": "https://test.example.com"}, + } + ) + ) + + def test_removes_env_and_preserves_others(self, tmp_path): + config_path = tmp_path / "config.yml" + self._write_two_env_config(config_path, default="PROD") + remove_environment_from_config(config_path, "TEST") + data = yaml.safe_load(config_path.read_text()) + assert "TEST" not in data + assert data["PROD"]["base_url"] == "https://prod.example.com" + # Removing a non-default env leaves the default untouched. + assert data["GENERAL"]["default_config"] == "PROD" + + def test_removing_default_env_clears_default(self, tmp_path): + # A dangling default_config would make ConfigFile refuse to load the file, so removing the + # environment that is the default must also clear the default. + config_path = tmp_path / "config.yml" + self._write_two_env_config(config_path, default="PROD") + remove_environment_from_config(config_path, "PROD") + data = yaml.safe_load(config_path.read_text()) + assert "PROD" not in data + assert data.get("GENERAL", {}).get("default_config") is None + # The result must still parse back through the reader. + config_file = ConfigFile.model_validate(data) + assert set(config_file.environments) == {"TEST"} + + def test_raises_on_unknown_env_and_leaves_file_unchanged(self, tmp_path): + config_path = tmp_path / "config.yml" + self._write_two_env_config(config_path) + before = config_path.read_text() + with pytest.raises(ValueError): + remove_environment_from_config(config_path, "NOPE") + assert config_path.read_text() == before + + def test_raises_on_missing_file(self, tmp_path): + config_path = tmp_path / "nonexistent.yml" + with pytest.raises(FileNotFoundError): + remove_environment_from_config(config_path, "PROD") + + def test_tightens_permissions(self, tmp_path): + config_path = tmp_path / "config.yml" + self._write_two_env_config(config_path) + config_path.chmod(0o644) + remove_environment_from_config(config_path, "TEST") + mode = stat.S_IMODE(os.stat(config_path).st_mode) + assert mode == 0o600 diff --git a/tests/bfabric_scripts/cli/login/test_cmd_auth_list.py b/tests/bfabric_scripts/cli/login/test_cmd_auth_list.py new file mode 100644 index 00000000..fc10be09 --- /dev/null +++ b/tests/bfabric_scripts/cli/login/test_cmd_auth_list.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import yaml + +from bfabric_scripts.cli.login.list import cmd_auth_list + + +class TestCmdAuthList: + def test_lists_environments_and_marks_default(self, tmp_path, capsys): + config_file = tmp_path / "config.yml" + config_file.write_text( + yaml.dump( + { + "GENERAL": {"default_config": "TEST"}, + "PROD": {"base_url": "https://prod.example.com", "auth_method": "oauth"}, + "TEST": {"base_url": "https://test.example.com", "auth_method": "pat", "pat": "tok"}, + } + ) + ) + cmd_auth_list(config_file=config_file) + output = capsys.readouterr().out + assert "PROD" in output + assert "TEST" in output + # The default is flagged and each row carries the host + auth method. + assert "(default)" in output + assert "prod.example.com" in output + assert "oauth" in output + assert "pat" in output + + def test_missing_config_file(self, tmp_path, capsys): + config_file = tmp_path / "nonexistent.yml" + cmd_auth_list(config_file=config_file) + output = capsys.readouterr().out + assert "not found" in output + + def test_no_environments(self, tmp_path, capsys): + config_file = tmp_path / "config.yml" + config_file.write_text(yaml.dump({"GENERAL": {}})) + cmd_auth_list(config_file=config_file) + output = capsys.readouterr().out + assert "No environments configured" in output diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py b/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py index a35a5742..72a0713d 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py @@ -5,7 +5,6 @@ import pytest import yaml -from bfabric._oauth.token_cache import compute_token_cache_path from bfabric_scripts.cli.login.logout import cmd_login_logout @@ -14,42 +13,122 @@ def _clear_config_env(monkeypatch): monkeypatch.delenv("BFABRICPY_CONFIG_ENV", raising=False) -class TestCmdLoginLogout: - def test_clears_oauth_token_cache(self, tmp_path, capsys): - config_file = tmp_path / "config.yml" - base_url = "https://example.com/bfabric" - client_id = "my-app" - config_file.write_text( - yaml.dump({ - "GENERAL": {"default_config": "PROD"}, - "PROD": { - "base_url": base_url, - "auth_method": "oauth", - "client_id": client_id, - }, - }) +def _write_config(config_file, *, default="PROD", extra_prod=None): + prod = {"base_url": "https://example.com/bfabric", "auth_method": "oauth", "client_id": "my-app"} + prod.update(extra_prod or {}) + config_file.write_text( + yaml.dump( + { + "GENERAL": {"default_config": default}, + "PROD": prod, + "TEST": {"base_url": "https://test.example.com/bfabric", "login": "user", "password": "x" * 32}, + } ) - cache_path = compute_token_cache_path(base_url, client_id, "PROD").expanduser() - cache_path.parent.mkdir(parents=True, exist_ok=True) + ) + + +def _environments(config_file): + data = yaml.safe_load(config_file.read_text()) + return {k for k in data if k != "GENERAL"} + + +class TestCmdLoginLogout: + def test_removes_oauth_env_and_clears_cache(self, tmp_path, capsys, mocker): + config_file = tmp_path / "config.yml" + _write_config(config_file, default="PROD") + cache_path = tmp_path / "tok.json" cache_path.write_text(json.dumps({"access_token": "tok"})) + mocker.patch("bfabric_scripts.cli.login.logout.compute_token_cache_path", return_value=cache_path) + + cmd_login_logout("PROD", config_file=config_file, no_confirm=True) - cmd_login_logout(config_file=config_file) output = capsys.readouterr().out - assert "cleared" in output + assert "Removed" in output assert not cache_path.exists() + # The environment is gone and, since it was the default, the default is cleared. + assert _environments(config_file) == {"TEST"} + data = yaml.safe_load(config_file.read_text()) + assert data.get("GENERAL", {}).get("default_config") is None + + def test_removes_non_oauth_env_without_touching_cache(self, tmp_path, capsys, mocker): + config_file = tmp_path / "config.yml" + _write_config(config_file, default="PROD") + clear = mocker.patch("bfabric_scripts.cli.login.logout.TokenCache") + + cmd_login_logout("TEST", config_file=config_file, no_confirm=True) + + assert _environments(config_file) == {"PROD"} + # A password environment has no OAuth token cache to clear. + clear.assert_not_called() + + def test_confirmation_declined_keeps_env(self, tmp_path, capsys, mocker): + config_file = tmp_path / "config.yml" + _write_config(config_file, default="PROD") + mocker.patch("bfabric_scripts.cli.login.logout.is_interactive", return_value=True) + mocker.patch("bfabric_scripts.cli.login.logout.confirm", return_value=False) + + cmd_login_logout("PROD", config_file=config_file) + + output = capsys.readouterr().out + assert "No changes made" in output + assert _environments(config_file) == {"PROD", "TEST"} + + def test_interactive_picker_selects_env(self, tmp_path, mocker): + config_file = tmp_path / "config.yml" + _write_config(config_file, default="PROD") + mocker.patch("bfabric_scripts.cli.login.logout.is_interactive", return_value=True) + pick = mocker.patch("bfabric_scripts.cli.login.logout.select_choice", return_value="TEST") + mocker.patch("bfabric_scripts.cli.login.logout.confirm", return_value=True) + + cmd_login_logout(config_file=config_file) + + pick.assert_called_once() + # The picker preselects the current default environment. + assert pick.call_args.kwargs["default"] == "PROD" + assert _environments(config_file) == {"PROD"} + + def test_interactive_picker_cancel_keeps_all(self, tmp_path, capsys, mocker): + config_file = tmp_path / "config.yml" + _write_config(config_file, default="PROD") + mocker.patch("bfabric_scripts.cli.login.logout.is_interactive", return_value=True) + mocker.patch("bfabric_scripts.cli.login.logout.select_choice", return_value=None) + + cmd_login_logout(config_file=config_file) + + assert _environments(config_file) == {"PROD", "TEST"} + + def test_non_interactive_without_env_aborts(self, tmp_path, capsys, mocker): + config_file = tmp_path / "config.yml" + _write_config(config_file, default="PROD") + mocker.patch("bfabric_scripts.cli.login.logout.is_interactive", return_value=False) - def test_non_oauth_env(self, tmp_path, capsys): - config_file = tmp_path / "config.yml" - config_file.write_text( - yaml.dump({ - "GENERAL": {"default_config": "PROD"}, - "PROD": { - "base_url": "https://example.com/bfabric", - "login": "user", - "password": "x" * 32, - }, - }) - ) cmd_login_logout(config_file=config_file) + + err = capsys.readouterr().err + assert "--config-env" in err + assert _environments(config_file) == {"PROD", "TEST"} + + def test_non_interactive_without_no_confirm_refuses(self, tmp_path, capsys, mocker): + config_file = tmp_path / "config.yml" + _write_config(config_file, default="PROD") + mocker.patch("bfabric_scripts.cli.login.logout.is_interactive", return_value=False) + + cmd_login_logout("PROD", config_file=config_file) + + err = capsys.readouterr().err + assert "confirm" in err.lower() + assert _environments(config_file) == {"PROD", "TEST"} + + def test_unknown_env(self, tmp_path, capsys): + config_file = tmp_path / "config.yml" + _write_config(config_file, default="PROD") + cmd_login_logout("NOPE", config_file=config_file, no_confirm=True) + output = capsys.readouterr().out + assert "not found" in output + assert _environments(config_file) == {"PROD", "TEST"} + + def test_missing_config_file(self, tmp_path, capsys): + config_file = tmp_path / "nonexistent.yml" + cmd_login_logout("PROD", config_file=config_file, no_confirm=True) output = capsys.readouterr().out - assert "does not use OAuth" in output + assert "not found" in output diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py b/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py index 4bd84c74..3f9933b7 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py @@ -63,6 +63,35 @@ def test_set_default_false_does_not_set_default(self, tmp_path, mocker): assert "default_config" not in data["GENERAL"] assert data["PROD"]["auth_method"] == "oauth" + def test_prompts_for_default_when_omitted(self, tmp_path, mocker): + config_file = tmp_path / "config.yml" + token = { + "access_token": "jwt123", + "refresh_token": "rt456", + "token_type": "Bearer", + "expires_at": time.time() + 3600, + } + mock_session = mocker.MagicMock() + mock_session.token = None + mock_session.metadata = {"token_endpoint": "https://example.com/bfabric/rest/oauth/token"} + mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=token) + mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) + # No --set-default given: in a terminal the user is asked; here they decline. + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) + confirm = mocker.patch("bfabric_scripts.cli.login._common.confirm", return_value=False) + cmd_login_pkce( + base_url="https://example.com/bfabric", + client_id="test-client", + config_env="PROD", + config_file=config_file, + scope="read-write", + ) + confirm.assert_called_once() + data = yaml.safe_load(config_file.read_text()) + # Declining the prompt means the environment is not made the default. + assert "default_config" not in data["GENERAL"] + assert data["PROD"]["auth_method"] == "oauth" + def test_scope_preset_is_expanded(self, tmp_path, mocker): config_file = tmp_path / "config.yml" token = { diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_status.py b/tests/bfabric_scripts/cli/login/test_cmd_login_status.py index 721412c1..341276dd 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_status.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_status.py @@ -1,8 +1,12 @@ from __future__ import annotations +import json +import time + import pytest import yaml +from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE from bfabric_scripts.cli.login.status import cmd_login_status @@ -70,6 +74,52 @@ def test_shows_pat_auth(self, tmp_path, capsys): # The secret itself must never be printed. assert "short-pat-token" not in output + def test_oauth_reports_missing_token_and_unrecorded_scope(self, tmp_path, capsys, mocker): + config_file = tmp_path / "config.yml" + config_file.write_text( + yaml.dump( + { + "GENERAL": {"default_config": "PROD"}, + "PROD": { + "base_url": "https://example.com/bfabric", + "auth_method": "oauth", + "client_id": "my-app", + }, + } + ) + ) + # No cache file on disk -> missing token, no scope to report. + mocker.patch("bfabric_scripts.cli.login.status.compute_token_cache_path", return_value=tmp_path / "absent.json") + cmd_login_status(config_file=config_file) + output = capsys.readouterr().out + assert "missing" in output + assert "(not recorded)" in output + + def test_oauth_shows_matched_scope_and_expiry_when_cached(self, tmp_path, capsys, mocker): + config_file = tmp_path / "config.yml" + config_file.write_text( + yaml.dump( + { + "GENERAL": {"default_config": "PROD"}, + "PROD": { + "base_url": "https://example.com/bfabric", + "auth_method": "oauth", + "client_id": "my-app", + }, + } + ) + ) + cache_path = tmp_path / "tok.json" + cache_path.write_text( + json.dumps({"access_token": "x", "scope": f"{DEFAULT_OAUTH_SCOPE} tus", "expires_at": time.time() + 9000}) + ) + mocker.patch("bfabric_scripts.cli.login.status.compute_token_cache_path", return_value=cache_path) + cmd_login_status(config_file=config_file) + output = capsys.readouterr().out + # The granted scope matches the read-write-upload preset and the token is still valid. + assert "read-write-upload" in output + assert "expires in" in output + def test_missing_config_file(self, tmp_path, capsys): config_file = tmp_path / "nonexistent.yml" cmd_login_status(config_file=config_file) diff --git a/tests/bfabric_scripts/cli/login/test_login_common.py b/tests/bfabric_scripts/cli/login/test_login_common.py index 44656892..a040dab5 100644 --- a/tests/bfabric_scripts/cli/login/test_login_common.py +++ b/tests/bfabric_scripts/cli/login/test_login_common.py @@ -3,7 +3,13 @@ import yaml from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE -from bfabric_scripts.cli.login._common import resolve_config_env, resolve_scope +from bfabric_scripts.cli.login._common import ( + describe_scope, + describe_token_cache, + resolve_config_env, + resolve_scope, + resolve_set_default, +) def _write_config(config_file, default="PROD"): @@ -93,3 +99,63 @@ def test_interactive_cancel_returns_none(self, mocker): mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) mocker.patch("bfabric_scripts.cli.login._common.select_choice", return_value=None) assert resolve_scope(None) is None + + +class TestDescribeScope: + def test_matched_preset_is_annotated(self): + # A granted scope equal to a preset (order-insensitive) is annotated with the preset name. + scope = f"tus {DEFAULT_OAUTH_SCOPE}" # reordered to prove match is order-insensitive + described = describe_scope(scope) + assert "read-write-upload" in described + assert "tus" in described + + def test_unmatched_scope_shown_raw(self): + assert describe_scope("api:read custom:thing") == "api:read custom:thing" + + def test_absent_scope_is_not_recorded(self): + assert describe_scope(None) == "(not recorded)" + assert describe_scope("") == "(not recorded)" + # A non-string (unexpected cache shape) must not blow up. + assert describe_scope(123) == "(not recorded)" + + +class TestDescribeTokenCache: + def test_missing_cache(self): + assert describe_token_cache(None, now=1000.0) == "missing" + + def test_present_without_expiry(self): + assert describe_token_cache({"access_token": "x"}, now=1000.0) == "present" + + def test_expired(self): + assert "expired" in describe_token_cache({"access_token": "x", "expires_at": 900}, now=1000.0) + + def test_valid_reports_remaining(self): + # 2h30m from now. + described = describe_token_cache({"access_token": "x", "expires_at": 1000 + 9000}, now=1000.0) + assert "present" in described + assert "2h" in described + + +class TestResolveSetDefault: + def test_explicit_true_honored_without_prompt(self, mocker): + confirm = mocker.patch("bfabric_scripts.cli.login._common.confirm") + assert resolve_set_default(True, "PROD") is True + confirm.assert_not_called() + + def test_explicit_false_honored_without_prompt(self, mocker): + confirm = mocker.patch("bfabric_scripts.cli.login._common.confirm") + assert resolve_set_default(False, "PROD") is False + confirm.assert_not_called() + + def test_non_interactive_defaults_to_true(self, mocker): + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=False) + confirm = mocker.patch("bfabric_scripts.cli.login._common.confirm") + assert resolve_set_default(None, "PROD") is True + confirm.assert_not_called() + + def test_interactive_prompts_preselected_yes(self, mocker): + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) + confirm = mocker.patch("bfabric_scripts.cli.login._common.confirm", return_value=False) + assert resolve_set_default(None, "PROD") is False + # The prompt is preselected to "yes". + assert confirm.call_args.kwargs["default"] is True diff --git a/tests/bfabric_scripts/cli/test_interactive.py b/tests/bfabric_scripts/cli/test_interactive.py index a21de65d..25f31319 100644 --- a/tests/bfabric_scripts/cli/test_interactive.py +++ b/tests/bfabric_scripts/cli/test_interactive.py @@ -2,7 +2,7 @@ import questionary -from bfabric_scripts.cli.interactive import resolve_choice, select_choice, select_or_input, text_input +from bfabric_scripts.cli.interactive import confirm, resolve_choice, select_choice, select_or_input, text_input class TestResolveChoice: @@ -82,6 +82,28 @@ def test_no_hint_when_no_choices(self, mocker): autocomplete.assert_called_once_with("Pick", choices=[], default="") +class TestConfirm: + def test_returns_true_when_confirmed(self, mocker): + question = mocker.MagicMock() + question.ask.return_value = True + confirm_prompt = mocker.patch("bfabric_scripts.cli.interactive.questionary.confirm", return_value=question) + assert confirm("Delete?") is True + confirm_prompt.assert_called_once_with("Delete?", default=False) + + def test_returns_false_when_declined(self, mocker): + question = mocker.MagicMock() + question.ask.return_value = False + mocker.patch("bfabric_scripts.cli.interactive.questionary.confirm", return_value=question) + assert confirm("Delete?") is False + + def test_cancel_is_treated_as_false(self, mocker): + # Ctrl-C / Esc yields None; for a destructive prompt that must mean "no". + question = mocker.MagicMock() + question.ask.return_value = None + mocker.patch("bfabric_scripts.cli.interactive.questionary.confirm", return_value=question) + assert confirm("Delete?") is False + + class TestTextInput: def test_returns_entered_value(self, mocker): question = mocker.MagicMock() From 170cee374312c3bf87653345ecc8730ceef6c6ea Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Wed, 15 Jul 2026 11:28:34 +0200 Subject: [PATCH 04/29] refactor(bfabric_scripts): de-duplicate auth login tests Extract the repeated OAuth token/session boilerplate and the thrice-copied BFABRICPY_CONFIG_ENV cleanup into a login-dir conftest fixture set, and drop the redundant repeated ask()-is-Any comments from the interactive wrappers. No behavior change; login + interactive suites (83 tests) green. --- .../src/bfabric_scripts/cli/interactive.py | 5 +- tests/bfabric_scripts/cli/login/conftest.py | 32 ++++++++ .../cli/login/test_cmd_auth_default.py | 6 -- .../cli/login/test_cmd_login_device_code.py | 47 ++---------- .../cli/login/test_cmd_login_logout.py | 6 -- .../cli/login/test_cmd_login_pkce.py | 75 +++---------------- .../cli/login/test_cmd_login_status.py | 6 -- 7 files changed, 49 insertions(+), 128 deletions(-) create mode 100644 tests/bfabric_scripts/cli/login/conftest.py diff --git a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py index a7e7e72a..cd21b75d 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py @@ -41,7 +41,7 @@ def select_choice( items = [questionary.Choice(title=describe(choice), value=choice) for choice in choices] else: items = list(choices) - # questionary's ``ask()`` is typed ``Any``; the prompt only ever yields a choice or None. + # questionary's ``ask()`` is typed ``Any``; every prompt here yields its value or None on cancel. # With the search filter on, j/k must stop being navigation keys (they'd be swallowed as # filter input) -- questionary raises otherwise. Arrow keys keep working regardless. return cast( @@ -61,7 +61,6 @@ def select_or_input(message: str, choices: Sequence[str], *, default: str | None # Autocomplete only reveals suggestions on Tab, which isn't discoverable -- hint it, but only # when there actually are suggestions to complete (a first-time prompt with no choices wouldn't). prompt = f"{message} (Tab to autocomplete)" if items else message - # questionary's ``ask()`` is typed ``Any``; the autocomplete prompt yields the text or None. answer = cast("str | None", questionary.autocomplete(prompt, choices=items, default=default or "").ask()) return answer or None @@ -71,7 +70,6 @@ def text_input(message: str, *, default: str = "") -> str | None: Returns the entered text, or ``None`` if the user cancels or submits an empty answer. """ - # questionary's ``ask()`` is typed ``Any``; the text prompt yields the entered string or None. answer = cast("str | None", questionary.text(message, default=default).ask()) return answer or None @@ -82,7 +80,6 @@ def confirm(message: str, *, default: bool = False) -> bool: A cancel (Ctrl-C / Esc) yields ``None`` from questionary, which is treated as ``False`` -- the safe answer for the destructive prompts this guards. """ - # questionary's ``ask()`` is typed ``Any``; the confirm prompt yields a bool or None. answer = cast("bool | None", questionary.confirm(message, default=default).ask()) return answer is True diff --git a/tests/bfabric_scripts/cli/login/conftest.py b/tests/bfabric_scripts/cli/login/conftest.py new file mode 100644 index 00000000..dc77916c --- /dev/null +++ b/tests/bfabric_scripts/cli/login/conftest.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import time + +import pytest + + +@pytest.fixture(autouse=True) +def _clear_config_env(monkeypatch): + """Drop the global ``__MOCK`` env so commands resolve the environment from the temp config.""" + monkeypatch.delenv("BFABRICPY_CONFIG_ENV", raising=False) + + +@pytest.fixture +def oauth_token(): + """A representative token-endpoint response, valid for another hour.""" + return { + "access_token": "jwt123", + "refresh_token": "rt456", + "token_type": "Bearer", + "expires_at": time.time() + 3600, + } + + +@pytest.fixture +def oauth_session(mocker): + """Patch ``OAuth2Session`` so the credential provider caches without a real network call.""" + session = mocker.MagicMock() + session.token = None + session.metadata = {"token_endpoint": "https://example.com/bfabric/rest/oauth/token"} + mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=session) + return session diff --git a/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py b/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py index df149b90..6732195b 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py @@ -1,16 +1,10 @@ from __future__ import annotations -import pytest import yaml from bfabric_scripts.cli.login.default_config import cmd_auth_default -@pytest.fixture(autouse=True) -def _clear_config_env(monkeypatch): - monkeypatch.delenv("BFABRICPY_CONFIG_ENV", raising=False) - - def _write_config(config_file, default="PROD"): general = {"default_config": default} if default is not None else {} config_file.write_text( diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py b/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py index 5b2f3170..e767da87 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py @@ -1,7 +1,5 @@ from __future__ import annotations -import time - import yaml from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE @@ -9,21 +7,9 @@ class TestCmdLoginDeviceCode: - def test_writes_config_and_caches_token(self, tmp_path, mocker): + def test_writes_config_and_caches_token(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" - token = { - "access_token": "jwt123", - "refresh_token": "rt456", - "token_type": "Bearer", - "expires_at": time.time() + 3600, - } - - mock_session = mocker.MagicMock() - mock_session.token = None - mock_session.metadata = {"token_endpoint": "https://example.com/bfabric/rest/oauth/token"} - - mock_dc = mocker.patch("bfabric_scripts.cli.login.device_code.device_code_login", return_value=token) - mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) + mock_dc = mocker.patch("bfabric_scripts.cli.login.device_code.device_code_login", return_value=oauth_token) cmd_login_device_code( base_url="https://example.com/bfabric", client_id="test-client", @@ -37,20 +23,9 @@ def test_writes_config_and_caches_token(self, tmp_path, mocker): assert data["PROD"]["client_id"] == "test-client" assert data["PROD"]["base_url"] == "https://example.com/bfabric" - def test_set_default_false_does_not_set_default(self, tmp_path, mocker): + def test_set_default_false_does_not_set_default(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" - token = { - "access_token": "jwt123", - "refresh_token": "rt456", - "token_type": "Bearer", - "expires_at": time.time() + 3600, - } - mock_session = mocker.MagicMock() - mock_session.token = None - mock_session.metadata = {"token_endpoint": "https://example.com/bfabric/rest/oauth/token"} - - mocker.patch("bfabric_scripts.cli.login.device_code.device_code_login", return_value=token) - mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) + mocker.patch("bfabric_scripts.cli.login.device_code.device_code_login", return_value=oauth_token) cmd_login_device_code( base_url="https://example.com/bfabric", client_id="test-client", @@ -63,19 +38,9 @@ def test_set_default_false_does_not_set_default(self, tmp_path, mocker): assert "default_config" not in data["GENERAL"] assert data["PROD"]["auth_method"] == "oauth" - def test_scope_preset_is_expanded(self, tmp_path, mocker): + def test_scope_preset_is_expanded(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" - token = { - "access_token": "jwt123", - "refresh_token": "rt456", - "token_type": "Bearer", - "expires_at": time.time() + 3600, - } - mock_session = mocker.MagicMock() - mock_session.token = None - mock_session.metadata = {"token_endpoint": "https://example.com/bfabric/rest/oauth/token"} - mock_dc = mocker.patch("bfabric_scripts.cli.login.device_code.device_code_login", return_value=token) - mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) + mock_dc = mocker.patch("bfabric_scripts.cli.login.device_code.device_code_login", return_value=oauth_token) cmd_login_device_code( base_url="https://example.com/bfabric", client_id="test-client", diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py b/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py index 72a0713d..f2c442e6 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py @@ -2,17 +2,11 @@ import json -import pytest import yaml from bfabric_scripts.cli.login.logout import cmd_login_logout -@pytest.fixture(autouse=True) -def _clear_config_env(monkeypatch): - monkeypatch.delenv("BFABRICPY_CONFIG_ENV", raising=False) - - def _write_config(config_file, *, default="PROD", extra_prod=None): prod = {"base_url": "https://example.com/bfabric", "auth_method": "oauth", "client_id": "my-app"} prod.update(extra_prod or {}) diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py b/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py index 3f9933b7..df5d9aba 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py @@ -1,7 +1,5 @@ from __future__ import annotations -import time - import yaml from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE @@ -9,21 +7,9 @@ class TestCmdLoginPkce: - def test_writes_config_and_caches_token(self, tmp_path, mocker): + def test_writes_config_and_caches_token(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" - token = { - "access_token": "jwt123", - "refresh_token": "rt456", - "token_type": "Bearer", - "expires_at": time.time() + 3600, - } - - mock_session = mocker.MagicMock() - mock_session.token = None - mock_session.metadata = {"token_endpoint": "https://example.com/bfabric/rest/oauth/token"} - - mock_pkce = mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=token) - mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) + mock_pkce = mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=oauth_token) cmd_login_pkce( base_url="https://example.com/bfabric", client_id="test-client", @@ -37,20 +23,9 @@ def test_writes_config_and_caches_token(self, tmp_path, mocker): assert data["PROD"]["client_id"] == "test-client" assert data["PROD"]["base_url"] == "https://example.com/bfabric" - def test_set_default_false_does_not_set_default(self, tmp_path, mocker): + def test_set_default_false_does_not_set_default(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" - token = { - "access_token": "jwt123", - "refresh_token": "rt456", - "token_type": "Bearer", - "expires_at": time.time() + 3600, - } - mock_session = mocker.MagicMock() - mock_session.token = None - mock_session.metadata = {"token_endpoint": "https://example.com/bfabric/rest/oauth/token"} - - mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=token) - mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) + mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=oauth_token) cmd_login_pkce( base_url="https://example.com/bfabric", client_id="test-client", @@ -63,19 +38,9 @@ def test_set_default_false_does_not_set_default(self, tmp_path, mocker): assert "default_config" not in data["GENERAL"] assert data["PROD"]["auth_method"] == "oauth" - def test_prompts_for_default_when_omitted(self, tmp_path, mocker): + def test_prompts_for_default_when_omitted(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" - token = { - "access_token": "jwt123", - "refresh_token": "rt456", - "token_type": "Bearer", - "expires_at": time.time() + 3600, - } - mock_session = mocker.MagicMock() - mock_session.token = None - mock_session.metadata = {"token_endpoint": "https://example.com/bfabric/rest/oauth/token"} - mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=token) - mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) + mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=oauth_token) # No --set-default given: in a terminal the user is asked; here they decline. mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) confirm = mocker.patch("bfabric_scripts.cli.login._common.confirm", return_value=False) @@ -92,19 +57,9 @@ def test_prompts_for_default_when_omitted(self, tmp_path, mocker): assert "default_config" not in data["GENERAL"] assert data["PROD"]["auth_method"] == "oauth" - def test_scope_preset_is_expanded(self, tmp_path, mocker): + def test_scope_preset_is_expanded(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" - token = { - "access_token": "jwt123", - "refresh_token": "rt456", - "token_type": "Bearer", - "expires_at": time.time() + 3600, - } - mock_session = mocker.MagicMock() - mock_session.token = None - mock_session.metadata = {"token_endpoint": "https://example.com/bfabric/rest/oauth/token"} - mock_pkce = mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=token) - mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) + mock_pkce = mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=oauth_token) cmd_login_pkce( base_url="https://example.com/bfabric", client_id="test-client", @@ -115,7 +70,7 @@ def test_scope_preset_is_expanded(self, tmp_path, mocker): # The preset name expands to the real scope string requested from the OAuth flow. assert mock_pkce.call_args.kwargs["scope"] == f"{DEFAULT_OAUTH_SCOPE} tus" - def test_config_env_omitted_falls_back_to_current_default(self, tmp_path, mocker): + def test_config_env_omitted_falls_back_to_current_default(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" config_file.write_text( yaml.dump( @@ -125,17 +80,7 @@ def test_config_env_omitted_falls_back_to_current_default(self, tmp_path, mocker } ) ) - token = { - "access_token": "jwt123", - "refresh_token": "rt456", - "token_type": "Bearer", - "expires_at": time.time() + 3600, - } - mock_session = mocker.MagicMock() - mock_session.token = None - mock_session.metadata = {"token_endpoint": "https://example.com/bfabric/rest/oauth/token"} - mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=token) - mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) + mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=oauth_token) # No TTY (pytest) and no --config-env => reuse the current default env, not PRODUCTION. mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=False) cmd_login_pkce(base_url="https://example.com/bfabric", client_id="c", config_file=config_file) diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_status.py b/tests/bfabric_scripts/cli/login/test_cmd_login_status.py index 341276dd..107da9f1 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_status.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_status.py @@ -3,18 +3,12 @@ import json import time -import pytest import yaml from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE from bfabric_scripts.cli.login.status import cmd_login_status -@pytest.fixture(autouse=True) -def _clear_config_env(monkeypatch): - monkeypatch.delenv("BFABRICPY_CONFIG_ENV", raising=False) - - class TestCmdLoginStatus: def test_shows_password_auth(self, tmp_path, capsys): config_file = tmp_path / "config.yml" From 4ba8c7db7495b0600c5194eb224826275ff185aa Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Wed, 15 Jul 2026 11:46:56 +0200 Subject: [PATCH 05/29] fix(bfabric_scripts): flag no-default state on auth logout; harden helpers - auth logout: when removing the current default while other environments remain, warn in the confirmation prompt and point to 'auth default' after, instead of silently leaving the config with no default. - auth logout: remove the config entry before clearing the token cache, so a failed write can't strand an environment without its token. - print_environments: guard the width computation against an empty mapping. Review fixes from the pre-PR readiness pass. --- .../src/bfabric_scripts/cli/interactive.py | 4 ++-- .../src/bfabric_scripts/cli/login/_common.py | 2 +- .../src/bfabric_scripts/cli/login/logout.py | 18 +++++++++++++++--- .../cli/login/test_cmd_login_logout.py | 13 +++++++++++++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py index cd21b75d..82404942 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py @@ -30,7 +30,7 @@ def select_choice( ) -> str | None: """Show an arrow-key menu of *choices* and return the picked value. - Returns ``None`` if the user cancels (Ctrl-C / Esc). *default* pre-selects an entry and + Returns ``None`` if the user cancels (Ctrl-C). *default* pre-selects an entry and must be one of *choices* (pass ``None`` otherwise). *describe* maps each value to the label shown in the menu (e.g. to append a host or auth method); the return value is still the plain choice, never its label. With *search*, the user can type to filter the list live @@ -77,7 +77,7 @@ def text_input(message: str, *, default: str = "") -> str | None: def confirm(message: str, *, default: bool = False) -> bool: """Ask a yes/no question, returning the answer as a bool. - A cancel (Ctrl-C / Esc) yields ``None`` from questionary, which is treated as ``False`` -- + A cancel (Ctrl-C) yields ``None`` from questionary, which is treated as ``False`` -- the safe answer for the destructive prompts this guards. """ answer = cast("bool | None", questionary.confirm(message, default=default).ask()) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py index b8317bbe..3eb9d4eb 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py @@ -199,6 +199,6 @@ def environment_line(name: str, *, summary: str, width: int, is_default: bool) - def print_environments(console: Console, environments: dict[str, EnvironmentConfig], default: str | None) -> None: """List the configured environments with their host/auth summary, marking the default.""" console.print("Configuration environments:") - width = max(len(name) for name in environments) + width = max((len(name) for name in environments), default=0) for name, env in environments.items(): console.print(environment_line(name, summary=environment_summary(env), width=width, is_default=name == default)) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py b/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py index d2126884..b4b41a04 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py @@ -70,6 +70,11 @@ def cmd_login_logout( return env = environments[config_env] + # Removing the current default leaves the config with no default (a dangling default would make + # it unloadable, so it is cleared). Only worth flagging when other environments remain to + # default to; otherwise "no default" is moot. + leaves_no_default = config_env == config_file_obj.general.default_config and len(names) > 1 + if not no_confirm: if not is_interactive(): print( @@ -77,17 +82,24 @@ def cmd_login_logout( file=sys.stderr, ) return - if not confirm( + prompt = ( f"Remove environment '{config_env}' ({environment_summary(env)})? " "This deletes its config entry and any cached OAuth tokens." - ): + ) + if leaves_no_default: + prompt += " It is the current default; afterwards no default will be set." + if not confirm(prompt): print("No changes made.") return + # Remove the config entry first: if that write fails, the cached token is left untouched so the + # environment stays fully usable, rather than losing its token to a half-completed removal. + remove_environment_from_config(config_path, config_env) if env.auth_method == "oauth": client_id = env.client_id or DEFAULT_CLIENT_ID cache_path = compute_token_cache_path(env.config.base_url.rstrip("/"), client_id, config_env).expanduser() TokenCache(cache_path).clear() - remove_environment_from_config(config_path, config_env) print(f"Removed environment '{config_env}'.") + if leaves_no_default: + print("It was the default environment; set a new default with 'bfabric-cli auth default '.") diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py b/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py index f2c442e6..5c2af7cc 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py @@ -44,6 +44,19 @@ def test_removes_oauth_env_and_clears_cache(self, tmp_path, capsys, mocker): data = yaml.safe_load(config_file.read_text()) assert data.get("GENERAL", {}).get("default_config") is None + def test_removing_default_env_points_to_auth_default(self, tmp_path, capsys, mocker): + config_file = tmp_path / "config.yml" + _write_config(config_file, default="PROD") + mocker.patch("bfabric_scripts.cli.login.logout.compute_token_cache_path", return_value=tmp_path / "tok.json") + + cmd_login_logout("PROD", config_file=config_file, no_confirm=True) + + output = capsys.readouterr().out + # Removing the default while TEST remains must tell the user no default is set and how to fix it. + assert "auth default" in output + data = yaml.safe_load(config_file.read_text()) + assert data.get("GENERAL", {}).get("default_config") is None + def test_removes_non_oauth_env_without_touching_cache(self, tmp_path, capsys, mocker): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") From a925d73f91c5029cfeda6a9cac310fcebedf1bdd Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Wed, 15 Jul 2026 14:53:28 +0200 Subject: [PATCH 06/29] refactor(oauth): require explicit client_id and scope in core OAuth API The core bfabric OAuth entry points no longer bake in a "CLI" client ID or a default scope. connect_oauth/connect_pkce/connect_device_code and the _oauth helpers now require client_id and scope explicitly. The CLI-facing defaults move to bfabric_scripts (cli/login/_constants.py). connect() with auth_method: oauth now raises if the config lacks client_id instead of falling back to "CLI". --- bfabric/docs/changelog.md | 1 + bfabric/docs/design/oauth_integration.md | 3 +- .../design/oauth_usage_and_troubleshooting.md | 11 +++-- bfabric/src/bfabric/_oauth/__init__.py | 3 -- bfabric/src/bfabric/_oauth/_constants.py | 4 -- .../src/bfabric/_oauth/credential_provider.py | 3 +- bfabric/src/bfabric/_oauth/device_code.py | 5 +-- bfabric/src/bfabric/_oauth/pkce.py | 5 +-- bfabric/src/bfabric/_oauth/registration.py | 6 +-- bfabric/src/bfabric/_oauth/webapp_client.py | 4 +- bfabric/src/bfabric/bfabric.py | 18 +++++--- bfabric_scripts/example/test_webapp_flow.py | 3 ++ .../bfabric_scripts/cli/login/_constants.py | 10 +++++ .../bfabric_scripts/cli/login/device_code.py | 2 +- .../src/bfabric_scripts/cli/login/logout.py | 3 +- .../src/bfabric_scripts/cli/login/pkce.py | 2 +- .../src/bfabric_scripts/cli/login/register.py | 3 +- .../cli/login/register_webapp.py | 12 ++--- .../src/bfabric_scripts/cli/login/status.py | 3 +- tests/bfabric/oauth/test_device_code.py | 11 +++-- tests/bfabric/oauth/test_pkce.py | 12 ++--- tests/bfabric/oauth/test_registration.py | 22 +++++++-- tests/bfabric/oauth/test_webapp_client.py | 22 +++------ tests/bfabric/test_bfabric.py | 45 ++++++++++++++----- .../cli/login/test_cmd_login_register.py | 2 +- 25 files changed, 121 insertions(+), 94 deletions(-) delete mode 100644 bfabric/src/bfabric/_oauth/_constants.py create mode 100644 bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py diff --git a/bfabric/docs/changelog.md b/bfabric/docs/changelog.md index 947ebd34..795885d9 100644 --- a/bfabric/docs/changelog.md +++ b/bfabric/docs/changelog.md @@ -10,6 +10,7 @@ Minor breaking changes are still possible in `1.X.Y` but we try to announce them ## \[Unreleased\] - `ResultContainer.to_polars()` returns an empty DataFrame for an empty result set instead of raising `polars.exceptions.NoDataError`, fixing a crash in `bfabric-cli api read` when a query matched no records. +- OAuth: `Bfabric.connect_oauth` / `connect_pkce` / `connect_device_code` (and the underlying `_oauth` helpers) now require explicit `client_id` and `scope` — the core library no longer bakes in a `"CLI"` client ID or a default scope. Those defaults are now CLI policy (`bfabric-cli auth …` supplies them unchanged). `connect()` with `auth_method: oauth` now raises if the config environment has no `client_id` instead of falling back to `"CLI"`. ## \[1.20.0rc2\] - 2026-07-15 diff --git a/bfabric/docs/design/oauth_integration.md b/bfabric/docs/design/oauth_integration.md index a7f803e5..776a9676 100644 --- a/bfabric/docs/design/oauth_integration.md +++ b/bfabric/docs/design/oauth_integration.md @@ -14,7 +14,6 @@ Private module under `bfabric/src/bfabric/_oauth/` implementing all OAuth primit | File | Purpose | |------|---------| -| `_constants.py` | `DEFAULT_CLIENT_ID = "CLI"`, `DEFAULT_OAUTH_SCOPE = "api:read api:write openid profile email groups"` | | `credential_provider.py` | `OAuthCredentialProvider` — thread-safe token management with automatic refresh and disk caching. Supports both `client_credentials` and `refresh_token` grant types. | | `pkce.py` | `pkce_login()` — browser-based PKCE flow. Starts a local HTTP server, opens the browser, exchanges the authorization code for tokens. | | `device_code.py` | `device_code_login()` — RFC 8628 device authorization flow for headless environments. | @@ -23,6 +22,8 @@ Private module under `bfabric/src/bfabric/_oauth/` implementing all OAuth primit | `url_token.py` | `UrlTokenContext` + `parse_url_token()` — extracts entity context (entity_id, application_id, etc.) from B-Fabric URL token JWTs. | | `webapp_client.py` | `WebappClient` — dual-identity client bundling a `user` (from URL token) and `service` (from client credentials) `Bfabric` instance. | +The core `_oauth` API is policy-free: `client_id` and `scope` are **required** arguments on all OAuth entry points — the library does not bake in a default client ID or scope. The CLI-facing defaults (`DEFAULT_CLIENT_ID = "CLI"`, `DEFAULT_OAUTH_SCOPE = "api:read api:write openid profile email groups"`) live with the CLI in `bfabric_scripts/.../cli/login/_constants.py`, where they belong as application policy. + --- ## New: Factory methods on `Bfabric` diff --git a/bfabric/docs/design/oauth_usage_and_troubleshooting.md b/bfabric/docs/design/oauth_usage_and_troubleshooting.md index 393094c3..c3c1a958 100644 --- a/bfabric/docs/design/oauth_usage_and_troubleshooting.md +++ b/bfabric/docs/design/oauth_usage_and_troubleshooting.md @@ -75,8 +75,10 @@ Key identity difference: **client_credentials tokens carry no user `sub` and no like `containers`.** If a resource server authorizes by *your* container membership, a service token won't do — you need a user flow (PKCE or device code). -`DEFAULT_OAUTH_SCOPE` is `"api:read api:write openid profile email groups"` — it **includes -`groups`** (the employee file-access path, see below) but not `containers` or `download`. +The core `Bfabric` OAuth methods take `client_id` and `scope` as **required** arguments — there is +no baked-in default. The `bfabric-cli` login commands default the scope to +`"api:read api:write openid profile email groups"` — it **includes `groups`** (the employee +file-access path, see below) but not `containers` or `download`. --- @@ -92,13 +94,14 @@ File/download access is authorized by **container membership**, which a token ca > **Employees: request the `groups` scope — this is the practical PKCE path.** > The **`containers` scope cannot be requested via PKCE** (it is restricted; the server silently > drops it — which is exactly why requesting `containers` in an interactive flow yields a token -> *without* the claim and no error). **`groups`, however, is requestable and is already in -> `DEFAULT_OAUTH_SCOPE`.** So an employee gets file access from a normal user flow as long as +> *without* the claim and no error). **`groups`, however, is requestable and is in the CLI's +> default scope.** So an employee gets file access from a normal user flow as long as > `groups` is in the requested scope: > > ```python > client = Bfabric.connect_pkce( > "https://fgcz-bfabric-demo.uzh.ch/bfabric", +> client_id="CLI", > scope="openid profile email api:read api:write groups", > ) > ``` diff --git a/bfabric/src/bfabric/_oauth/__init__.py b/bfabric/src/bfabric/_oauth/__init__.py index e2353580..43eac085 100644 --- a/bfabric/src/bfabric/_oauth/__init__.py +++ b/bfabric/src/bfabric/_oauth/__init__.py @@ -1,6 +1,5 @@ """OAuth integration for bfabricPy.""" -from bfabric._oauth._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE from bfabric._oauth.credential_provider import OAuthCredentialProvider from bfabric._oauth.device_code import device_code_login from bfabric._oauth.pkce import pkce_login @@ -10,8 +9,6 @@ from bfabric._oauth.webapp_client import WebappClient __all__ = [ - "DEFAULT_CLIENT_ID", - "DEFAULT_OAUTH_SCOPE", "OAuthCredentialProvider", "UrlTokenContext", "WebappClient", diff --git a/bfabric/src/bfabric/_oauth/_constants.py b/bfabric/src/bfabric/_oauth/_constants.py deleted file mode 100644 index e9ad9d48..00000000 --- a/bfabric/src/bfabric/_oauth/_constants.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Shared constants for the OAuth module.""" - -DEFAULT_CLIENT_ID = "CLI" -DEFAULT_OAUTH_SCOPE = "api:read api:write openid profile email groups" diff --git a/bfabric/src/bfabric/_oauth/credential_provider.py b/bfabric/src/bfabric/_oauth/credential_provider.py index ec491ed4..3c58b695 100644 --- a/bfabric/src/bfabric/_oauth/credential_provider.py +++ b/bfabric/src/bfabric/_oauth/credential_provider.py @@ -23,7 +23,6 @@ from loguru import logger from bfabric.config.bfabric_auth import OAUTH_LOGIN, BfabricAuth -from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE from bfabric._oauth.token_cache import TokenCache if TYPE_CHECKING: @@ -62,7 +61,7 @@ def __init__( client_secret: str, token_url: str, *, - scope: str = DEFAULT_OAUTH_SCOPE, + scope: str = "", token: dict[str, object] | None = None, grant_type: str = "client_credentials", token_cache_path: Path | None = None, diff --git a/bfabric/src/bfabric/_oauth/device_code.py b/bfabric/src/bfabric/_oauth/device_code.py index 89f03bc7..71132374 100644 --- a/bfabric/src/bfabric/_oauth/device_code.py +++ b/bfabric/src/bfabric/_oauth/device_code.py @@ -18,7 +18,6 @@ import httpx from loguru import logger -from bfabric._oauth._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE from bfabric.errors import BfabricOAuthError @@ -139,8 +138,8 @@ def _poll_for_token( def device_code_login( base_url: str, *, - client_id: str = DEFAULT_CLIENT_ID, - scope: str = DEFAULT_OAUTH_SCOPE, + client_id: str, + scope: str, timeout: float = 600.0, ) -> dict[str, object]: """Perform an OAuth 2.0 Device Authorization Grant flow (RFC 8628). diff --git a/bfabric/src/bfabric/_oauth/pkce.py b/bfabric/src/bfabric/_oauth/pkce.py index 20a057e2..d34a3ec6 100644 --- a/bfabric/src/bfabric/_oauth/pkce.py +++ b/bfabric/src/bfabric/_oauth/pkce.py @@ -25,7 +25,6 @@ import httpx from loguru import logger -from bfabric._oauth._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE from bfabric.errors import BfabricOAuthError @@ -128,8 +127,8 @@ def _exchange_code( def pkce_login( base_url: str, *, - client_id: str = DEFAULT_CLIENT_ID, - scope: str = DEFAULT_OAUTH_SCOPE, + client_id: str, + scope: str, port: int = 0, open_browser: bool = True, timeout: float = 120.0, diff --git a/bfabric/src/bfabric/_oauth/registration.py b/bfabric/src/bfabric/_oauth/registration.py index 70cf6360..5d92c5ee 100644 --- a/bfabric/src/bfabric/_oauth/registration.py +++ b/bfabric/src/bfabric/_oauth/registration.py @@ -7,8 +7,6 @@ import httpx from loguru import logger -from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE - if TYPE_CHECKING: from bfabric.bfabric import Bfabric from bfabric.results.result_container import ResultContainer @@ -40,7 +38,7 @@ def register_client( redirect_uri: str, *, service_user: str | None = None, - scope: str = DEFAULT_OAUTH_SCOPE, + scope: str, grant_types: list[str] | None = None, ) -> dict[str, object]: """Register a new OAuth client with the B-Fabric server. @@ -91,7 +89,7 @@ def register_webapp( web_url: str, *, service_user: str | None = None, - scope: str = DEFAULT_OAUTH_SCOPE, + scope: str, application_id: int | None = None, technology_id: int | None = None, description: str | None = None, diff --git a/bfabric/src/bfabric/_oauth/webapp_client.py b/bfabric/src/bfabric/_oauth/webapp_client.py index 34d510fd..5111ceee 100644 --- a/bfabric/src/bfabric/_oauth/webapp_client.py +++ b/bfabric/src/bfabric/_oauth/webapp_client.py @@ -5,8 +5,6 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE - if TYPE_CHECKING: from pathlib import Path @@ -35,7 +33,7 @@ def create( *, client_id: str, client_secret: str, - scope: str = DEFAULT_OAUTH_SCOPE, + scope: str, user_token_cache_path: Path | None = None, service_token_cache_path: Path | None = None, ) -> WebappClient: diff --git a/bfabric/src/bfabric/bfabric.py b/bfabric/src/bfabric/bfabric.py index 83e01420..4e39c954 100644 --- a/bfabric/src/bfabric/bfabric.py +++ b/bfabric/src/bfabric/bfabric.py @@ -28,7 +28,6 @@ from loguru import logger from rich.console import Console -from bfabric._oauth._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE from bfabric.config import DEFAULT_CONFIG_FILE, BfabricAuth, BfabricClientConfig from bfabric.config.bfabric_client_config import BfabricAPIEngineType from bfabric.config.config_data import ConfigData, load_config_data @@ -128,7 +127,12 @@ def _connect_oauth_from_config(cls, config_data: ConfigData) -> Bfabric: from bfabric._oauth.token_cache import TokenCache, compute_token_cache_path base_url = config_data.client.base_url.rstrip("/") - client_id = config_data.client_id or DEFAULT_CLIENT_ID + if not config_data.client_id: + raise ValueError( + "OAuth config is missing 'client_id'. Set it in the config environment " + "(e.g. re-run 'bfabric-cli auth pkce' or 'bfabric-cli auth device-code')." + ) + client_id = config_data.client_id env_name = config_data.env_name or "default" cache_path = compute_token_cache_path(base_url, client_id, env_name).expanduser() if not TokenCache(cache_path).load(): @@ -230,7 +234,7 @@ def connect_oauth( client_secret: str, base_url: str, *, - scope: str = DEFAULT_OAUTH_SCOPE, + scope: str, token_cache_path: Path | None = None, ) -> Bfabric: """Returns a new Bfabric instance that authenticates via OAuth 2.0 client credentials. @@ -266,8 +270,8 @@ def connect_pkce( cls, base_url: str, *, - client_id: str = DEFAULT_CLIENT_ID, - scope: str = DEFAULT_OAUTH_SCOPE, + client_id: str, + scope: str, port: int = 0, open_browser: bool = True, timeout: float = 120.0, @@ -318,8 +322,8 @@ def connect_device_code( cls, base_url: str, *, - client_id: str = DEFAULT_CLIENT_ID, - scope: str = DEFAULT_OAUTH_SCOPE, + client_id: str, + scope: str, timeout: float = 600.0, token_cache_path: Path | None = None, ) -> Bfabric: diff --git a/bfabric_scripts/example/test_webapp_flow.py b/bfabric_scripts/example/test_webapp_flow.py index 8a96a5d2..d4f5f008 100644 --- a/bfabric_scripts/example/test_webapp_flow.py +++ b/bfabric_scripts/example/test_webapp_flow.py @@ -26,6 +26,7 @@ from bfabric._oauth.registration import register_webapp from bfabric._oauth.webapp_client import WebappClient from bfabric.entities.core.uri import EntityUri +from bfabric_scripts.cli.login._constants import DEFAULT_OAUTH_SCOPE PORT = 19876 REDIRECT_PATH = "/callback" @@ -56,6 +57,7 @@ def main() -> None: app_name=app_name, web_url=web_url, service_user="itfeeder", + scope=DEFAULT_OAUTH_SCOPE, hidden=True, ) oauth_info = result["oauth"] @@ -149,6 +151,7 @@ def serve() -> None: launch_token=jwt, client_id=oauth_client_id, client_secret=oauth_client_secret, + scope=DEFAULT_OAUTH_SCOPE, ) except Exception as e: print(f"\nERROR: Token exchange failed: {e}", file=sys.stderr) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py new file mode 100644 index 00000000..540d6a6d --- /dev/null +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py @@ -0,0 +1,10 @@ +"""Default OAuth client ID and scope for the CLI login commands. + +These are CLI-level policy: ``"CLI"`` is the client identity the CLI registers +as, and the scope string is the set of permissions the CLI requests. The core +``bfabric`` library deliberately does not bake in these defaults — its OAuth +API requires callers to state ``client_id`` and ``scope`` explicitly. +""" + +DEFAULT_CLIENT_ID = "CLI" +DEFAULT_OAUTH_SCOPE = "api:read api:write openid profile email groups" diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py index fa8412ad..c7478feb 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py @@ -7,12 +7,12 @@ import cyclopts -from bfabric._oauth._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE from bfabric._oauth.credential_provider import OAuthCredentialProvider from bfabric._oauth.device_code import device_code_login from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE def cmd_login_device_code( diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py b/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py index 62607474..d55a3876 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py @@ -9,11 +9,10 @@ import cyclopts import yaml -from bfabric._oauth._constants import DEFAULT_CLIENT_ID - from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_file import ConfigFile from bfabric._oauth.token_cache import TokenCache, compute_token_cache_path +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID def cmd_login_logout( diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py index 46cd849a..25433a01 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py @@ -7,12 +7,12 @@ import cyclopts -from bfabric._oauth._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE from bfabric._oauth.credential_provider import OAuthCredentialProvider from bfabric._oauth.pkce import pkce_login from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE def cmd_login_pkce( diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/register.py b/bfabric_scripts/src/bfabric_scripts/cli/login/register.py index 2f2cf777..2c28728f 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/register.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/register.py @@ -10,9 +10,9 @@ import cyclopts -from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE from bfabric._oauth.registration import register_client from bfabric.config import DEFAULT_CONFIG_FILE +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE def _resolve_token_from_config(config_env: str, config_file: Path) -> tuple[str, str]: @@ -23,7 +23,6 @@ def _resolve_token_from_config(config_env: str, config_file: Path) -> tuple[str, """ import yaml - from bfabric._oauth._constants import DEFAULT_CLIENT_ID from bfabric._oauth.credential_provider import OAuthCredentialProvider from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config.config_file import ConfigFile diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py b/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py index 49df473e..78cf599b 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py @@ -9,17 +9,15 @@ import cyclopts -from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE from bfabric.config import DEFAULT_CONFIG_FILE +from bfabric_scripts.cli.login._constants import DEFAULT_OAUTH_SCOPE def cmd_login_register_webapp( app_name: Annotated[str, cyclopts.Parameter(help="Human-readable name for the webapp.")], web_url: Annotated[str, cyclopts.Parameter(help="The webapp URL (used as redirect URI and application weburl).")], *, - config_env: Annotated[ - str | None, cyclopts.Parameter(help="Config environment to use.") - ] = None, + config_env: Annotated[str | None, cyclopts.Parameter(help="Config environment to use.")] = None, config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, service_user: Annotated[ str | None, cyclopts.Parameter(help="Service user login (enables client_credentials grant).") @@ -28,9 +26,7 @@ def cmd_login_register_webapp( application_id: Annotated[ int | None, cyclopts.Parameter(help="Existing application ID to update (omit to create new).") ] = None, - technology_id: Annotated[ - int | None, cyclopts.Parameter(help="Technology ID for the application.") - ] = None, + technology_id: Annotated[int | None, cyclopts.Parameter(help="Technology ID for the application.")] = None, description: Annotated[str | None, cyclopts.Parameter(help="Application description.")] = None, ) -> None: """Register a new OAuth webapp: create OAuth client and B-Fabric application. @@ -73,4 +69,4 @@ def cmd_login_register_webapp( print( f"\nApplication saved. OAuth client id={oauth_info['id']}, client_id={oauth_info['client_id']}", file=sys.stderr, - ) \ No newline at end of file + ) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/status.py b/bfabric_scripts/src/bfabric_scripts/cli/login/status.py index c5d8588d..955b5cf5 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/status.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/status.py @@ -9,11 +9,10 @@ import cyclopts import yaml -from bfabric._oauth._constants import DEFAULT_CLIENT_ID - from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_file import ConfigFile from bfabric._oauth.token_cache import TokenCache, compute_token_cache_path +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID def cmd_login_status( diff --git a/tests/bfabric/oauth/test_device_code.py b/tests/bfabric/oauth/test_device_code.py index 87053c7e..0454d38d 100644 --- a/tests/bfabric/oauth/test_device_code.py +++ b/tests/bfabric/oauth/test_device_code.py @@ -3,7 +3,6 @@ import httpx import pytest -from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE from bfabric._oauth.device_code import ( _poll_for_token, _request_device_code, @@ -290,7 +289,7 @@ def test_prints_verification_uri_complete(self, mocker, capsys): mocker.patch("bfabric._oauth.device_code._request_device_code", return_value=device_response) mocker.patch("bfabric._oauth.device_code._poll_for_token", return_value={"access_token": "at"}) - device_code_login("https://example.com/bfabric") + device_code_login("https://example.com/bfabric", client_id="test-cli", scope="api:read") captured = capsys.readouterr() assert "https://example.com/device?user_code=WXYZ-9876" in captured.err @@ -311,12 +310,12 @@ def test_strips_trailing_slash(self, mocker): "bfabric._oauth.device_code._poll_for_token", return_value={"access_token": "at"}, ) - device_code_login("https://example.com/bfabric///") + device_code_login("https://example.com/bfabric///", client_id="test-cli", scope="api:read") mock_request.assert_called_once_with( "https://example.com/bfabric", - client_id="CLI", - scope=DEFAULT_OAUTH_SCOPE, + client_id="test-cli", + scope="api:read", ) assert mock_poll.call_args[0][0] == "https://example.com/bfabric" @@ -332,6 +331,6 @@ def test_default_interval_when_missing(self, mocker): "bfabric._oauth.device_code._poll_for_token", return_value={"access_token": "at"}, ) - device_code_login("https://example.com/bfabric") + device_code_login("https://example.com/bfabric", client_id="test-cli", scope="api:read") assert mock_poll.call_args[1]["interval"] == 5.0 diff --git a/tests/bfabric/oauth/test_pkce.py b/tests/bfabric/oauth/test_pkce.py index 4a77cbc1..5623f0b0 100644 --- a/tests/bfabric/oauth/test_pkce.py +++ b/tests/bfabric/oauth/test_pkce.py @@ -171,7 +171,7 @@ def test_happy_path(self, mocker): mock_server.serve_forever = mocker.MagicMock() mock_server_cls.return_value = mock_server - result = pkce_login("https://example.com/bfabric", client_id="test-cli") + result = pkce_login("https://example.com/bfabric", client_id="test-cli", scope="api:read") assert result == token_dict mock_exchange.assert_called_once_with( @@ -196,7 +196,7 @@ def test_state_mismatch_raises(self, mocker): mock_server_cls.return_value = mock_server with pytest.raises(BfabricOAuthError, match="CSRF state mismatch") as exc_info: - pkce_login("https://example.com/bfabric") + pkce_login("https://example.com/bfabric", client_id="test-cli", scope="api:read") # State values must NOT appear in the error message (security) assert "expected_state" not in str(exc_info.value) assert "wrong_state" not in str(exc_info.value) @@ -219,7 +219,7 @@ def test_error_from_server_raises(self, mocker): mock_server_cls.return_value = mock_server with pytest.raises(RuntimeError, match="Authorization error: access_denied"): - pkce_login("https://example.com/bfabric") + pkce_login("https://example.com/bfabric", client_id="test-cli", scope="api:read") def test_timeout_raises(self, mocker): mock_server_cls = mocker.patch("bfabric._oauth.pkce._CallbackServer") @@ -238,7 +238,7 @@ def test_timeout_raises(self, mocker): mock_thread_cls.return_value = mock_thread with pytest.raises(RuntimeError, match="timed out"): - pkce_login("https://example.com/bfabric", timeout=0.1) + pkce_login("https://example.com/bfabric", client_id="test-cli", scope="api:read", timeout=0.1) def test_browser_fallback_prints_url(self, mocker, capsys): mock_server_cls = mocker.patch("bfabric._oauth.pkce._CallbackServer") @@ -254,7 +254,7 @@ def test_browser_fallback_prints_url(self, mocker, capsys): mock_server.serve_forever = mocker.MagicMock() mock_server_cls.return_value = mock_server - pkce_login("https://example.com/bfabric") + pkce_login("https://example.com/bfabric", client_id="test-cli", scope="api:read") captured = capsys.readouterr() assert "Open this URL to log in:" in captured.err @@ -273,7 +273,7 @@ def test_open_browser_false_prints_url(self, mocker, capsys): mock_server.serve_forever = mocker.MagicMock() mock_server_cls.return_value = mock_server - pkce_login("https://example.com/bfabric", open_browser=False) + pkce_login("https://example.com/bfabric", client_id="test-cli", scope="api:read", open_browser=False) captured = capsys.readouterr() assert "Open this URL to log in:" in captured.err diff --git a/tests/bfabric/oauth/test_registration.py b/tests/bfabric/oauth/test_registration.py index 0aec4bcb..b540a9d1 100644 --- a/tests/bfabric/oauth/test_registration.py +++ b/tests/bfabric/oauth/test_registration.py @@ -2,9 +2,11 @@ import pytest -from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE from bfabric._oauth.registration import register_client, register_webapp +# register_client/register_webapp now require an explicit scope; tests pass this. +_TEST_SCOPE = "api:read api:write" + @pytest.fixture def mock_httpx_post(mocker): @@ -29,6 +31,7 @@ def test_basic_registration(self, mock_httpx_post): token="bearer-token", client_name="my-app", redirect_uri="http://localhost:8050/callback", + scope=_TEST_SCOPE, ) mock_httpx_post.assert_called_once_with( @@ -37,7 +40,7 @@ def test_basic_registration(self, mock_httpx_post): "client_name": "my-app", "redirect_uris": ["http://localhost:8050/callback"], "grant_types": [_TOKEN_EXCHANGE, "refresh_token", "authorization_code"], - "scope": DEFAULT_OAUTH_SCOPE, + "scope": _TEST_SCOPE, }, headers={"Authorization": "Bearer bearer-token"}, timeout=30, @@ -52,6 +55,7 @@ def test_with_service_user_adds_client_credentials_grant(self, mock_httpx_post): client_name="app", redirect_uri="http://localhost/cb", service_user="gfeeder", + scope=_TEST_SCOPE, ) call_body = mock_httpx_post.call_args[1]["json"] @@ -69,6 +73,7 @@ def test_without_service_user_no_client_credentials_grant(self, mock_httpx_post) token="tok", client_name="app", redirect_uri="http://localhost/cb", + scope=_TEST_SCOPE, ) call_body = mock_httpx_post.call_args[1]["json"] @@ -94,6 +99,7 @@ def test_explicit_grant_types_override(self, mock_httpx_post): client_name="app", redirect_uri="http://localhost/cb", grant_types=["authorization_code", "refresh_token"], + scope=_TEST_SCOPE, ) call_body = mock_httpx_post.call_args[1]["json"] @@ -105,11 +111,12 @@ def test_without_optional_params(self, mock_httpx_post): token="tok", client_name="app", redirect_uri="http://localhost/cb", + scope=_TEST_SCOPE, ) call_body = mock_httpx_post.call_args[1]["json"] assert "service_user_login" not in call_body - assert call_body["scope"] == DEFAULT_OAUTH_SCOPE + assert call_body["scope"] == _TEST_SCOPE def test_normalizes_trailing_slash(self, mock_httpx_post): register_client( @@ -117,6 +124,7 @@ def test_normalizes_trailing_slash(self, mock_httpx_post): token="tok", client_name="app", redirect_uri="http://localhost/cb", + scope=_TEST_SCOPE, ) url = mock_httpx_post.call_args[0][0] @@ -138,6 +146,7 @@ def test_raises_on_http_error(self, mocker): token="bad-token", client_name="app", redirect_uri="http://localhost/cb", + scope=_TEST_SCOPE, ) @@ -168,6 +177,7 @@ def test_calls_register_client_and_saves_application(self, mock_client, mock_reg token="bearer-tok", app_name="My Webapp", web_url="https://myapp.example.com/", + scope=_TEST_SCOPE, ) mock_register.assert_called_once_with( @@ -176,7 +186,7 @@ def test_calls_register_client_and_saves_application(self, mock_client, mock_reg client_name="My Webapp", redirect_uri="https://myapp.example.com/", service_user=None, - scope=DEFAULT_OAUTH_SCOPE, + scope=_TEST_SCOPE, ) mock_client.save.assert_called_once_with( "application", @@ -197,6 +207,7 @@ def test_forwards_service_user(self, mock_client, mock_register): app_name="app", web_url="https://app.example.com/", service_user="svc", + scope=_TEST_SCOPE, ) assert mock_register.call_args[1]["service_user"] == "svc" @@ -219,6 +230,7 @@ def test_updates_existing_application(self, mock_client, mock_register): app_name="app", web_url="https://app.example.com/", application_id=42, + scope=_TEST_SCOPE, ) save_obj = mock_client.save.call_args[0][1] @@ -232,6 +244,7 @@ def test_sets_optional_fields(self, mock_client, mock_register): web_url="https://app.example.com/", technology_id=7, description="A test app", + scope=_TEST_SCOPE, ) save_obj = mock_client.save.call_args[0][1] @@ -244,6 +257,7 @@ def test_omits_optional_fields_when_not_provided(self, mock_client, mock_registe token="tok", app_name="app", web_url="https://app.example.com/", + scope=_TEST_SCOPE, ) save_obj = mock_client.save.call_args[0][1] diff --git a/tests/bfabric/oauth/test_webapp_client.py b/tests/bfabric/oauth/test_webapp_client.py index b6a01306..48bc3c18 100644 --- a/tests/bfabric/oauth/test_webapp_client.py +++ b/tests/bfabric/oauth/test_webapp_client.py @@ -4,7 +4,6 @@ import pytest -from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE from bfabric._oauth.url_token import UrlTokenContext from bfabric._oauth.webapp_client import WebappClient @@ -52,6 +51,7 @@ def test_returns_correct_user_service_context(self, mocker, mock_token_dict, moc launch_token="short.lived.jwt", client_id="app-id", client_secret="app-secret", + scope="api:read", ) assert wc.service is mock_service_client @@ -71,6 +71,7 @@ def test_forwards_parameters_to_exchange_token(self, mocker, mock_token_dict): launch_token="my.launch.jwt", client_id="cid", client_secret="csecret", + scope="api:read", ) mock_exchange.assert_called_once_with( @@ -92,6 +93,7 @@ def test_verifies_exchanged_access_token_jwt(self, mocker, mock_token_dict): launch_token="jwt", client_id="cid", client_secret="csecret", + scope="api:read", ) mock_verify.assert_called_once_with( @@ -111,6 +113,7 @@ def test_creates_user_provider_with_refresh_token(self, mocker, mock_token_dict) launch_token="jwt", client_id="cid", client_secret="csecret", + scope="api:read", user_token_cache_path="/tmp/user_cache", ) @@ -147,22 +150,6 @@ def test_forwards_parameters_to_connect_oauth(self, mocker, mock_token_dict): token_cache_path="/tmp/svc_cache", ) - def test_default_scope(self, mocker, mock_token_dict): - mocker.patch(_PATCH_EXCHANGE, return_value=mock_token_dict) - mocker.patch(_PATCH_VERIFY_JWT, return_value=dict(SAMPLE_CLAIMS)) - mocker.patch(_PATCH_PROVIDER) - mock_connect_oauth = mocker.patch(_PATCH_CONNECT_OAUTH, return_value=mocker.MagicMock()) - mocker.patch(_PATCH_LOG) - - WebappClient.create( - base_url="https://bfabric.example.com/bfabric", - launch_token="jwt", - client_id="cid", - client_secret="csecret", - ) - - assert mock_connect_oauth.call_args.kwargs["scope"] == DEFAULT_OAUTH_SCOPE - class TestWebappClientFrozen: @pytest.fixture @@ -207,6 +194,7 @@ def test_user_and_service_are_independent(self, mocker): launch_token="jwt", client_id="cid", client_secret="csecret", + scope="api:read", ) assert wc.user is not wc.service diff --git a/tests/bfabric/test_bfabric.py b/tests/bfabric/test_bfabric.py index 96360787..3812b133 100644 --- a/tests/bfabric/test_bfabric.py +++ b/tests/bfabric/test_bfabric.py @@ -6,13 +6,15 @@ from pydantic import SecretStr from bfabric import Bfabric, BfabricAPIEngineType, BfabricClientConfig, BfabricAuth -from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.bfabric_auth import OAUTH_LOGIN from bfabric.config.config_data import ConfigData from bfabric.engine.engine_suds import EngineSUDS from bfabric.entities.core.entity_reader import EntityReader +# The core OAuth API no longer bakes in a default scope; tests pass it explicitly. +_TEST_SCOPE = "api:read api:write" + @pytest.fixture def mock_config(): @@ -449,6 +451,19 @@ def test_repr(bfabric_instance, variant): ) +class TestConnectOAuthFromConfig: + def test_raises_when_client_id_missing(self, mocker): + mocker.patch.object(Bfabric, "_log_version_message") + config_data = ConfigData( + client=BfabricClientConfig(base_url="https://example.com/bfabric"), + auth=None, + auth_method="oauth", + client_id=None, + ) + with pytest.raises(ValueError, match="missing 'client_id'"): + Bfabric._connect_oauth_from_config(config_data) + + class TestConnectOAuth: def test_creates_instance_with_provider(self, mocker): mocker.patch.object(Bfabric, "_log_version_message") @@ -458,13 +473,14 @@ def test_creates_instance_with_provider(self, mocker): client_id="my-id", client_secret="my-secret", base_url="https://example.com/bfabric", + scope=_TEST_SCOPE, ) mock_provider_cls.assert_called_once_with( client_id="my-id", client_secret="my-secret", token_url="https://example.com/bfabric/rest/oauth/token", - scope=DEFAULT_OAUTH_SCOPE, + scope=_TEST_SCOPE, grant_type="client_credentials", token_cache_path=None, ) @@ -496,6 +512,7 @@ def test_strips_trailing_slash(self, mocker): client_id="id", client_secret="secret", base_url="https://example.com/bfabric/", + scope=_TEST_SCOPE, ) assert client.config.base_url == "https://example.com/bfabric/" @@ -559,23 +576,25 @@ def test_creates_instance_with_provider(self, mocker): client = Bfabric.connect_pkce( base_url="https://example.com/bfabric", + client_id="my-cli", + scope=_TEST_SCOPE, ) mock_pkce_login.assert_called_once_with( "https://example.com/bfabric", - client_id="CLI", - scope=DEFAULT_OAUTH_SCOPE, + client_id="my-cli", + scope=_TEST_SCOPE, port=0, open_browser=True, timeout=120.0, ) mock_provider_cls.assert_called_once_with( - client_id="CLI", + client_id="my-cli", client_secret="", token_url="https://example.com/bfabric/rest/oauth/token", token=mock_pkce_login.return_value, grant_type="refresh_token", - scope=DEFAULT_OAUTH_SCOPE, + scope=_TEST_SCOPE, token_cache_path=None, ) assert client._credential_provider == mock_provider_cls.return_value @@ -620,6 +639,8 @@ def test_strips_trailing_slash(self, mocker): client = Bfabric.connect_pkce( base_url="https://example.com/bfabric///", + client_id="my-cli", + scope=_TEST_SCOPE, ) assert client.config.base_url == "https://example.com/bfabric/" @@ -640,21 +661,23 @@ def test_creates_instance_with_provider(self, mocker): client = Bfabric.connect_device_code( base_url="https://example.com/bfabric", + client_id="my-cli", + scope=_TEST_SCOPE, ) mock_device_code_login.assert_called_once_with( "https://example.com/bfabric", - client_id="CLI", - scope=DEFAULT_OAUTH_SCOPE, + client_id="my-cli", + scope=_TEST_SCOPE, timeout=600.0, ) mock_provider_cls.assert_called_once_with( - client_id="CLI", + client_id="my-cli", client_secret="", token_url="https://example.com/bfabric/rest/oauth/token", token=mock_device_code_login.return_value, grant_type="refresh_token", - scope=DEFAULT_OAUTH_SCOPE, + scope=_TEST_SCOPE, token_cache_path=None, ) assert client._credential_provider == mock_provider_cls.return_value @@ -695,6 +718,8 @@ def test_strips_trailing_slash(self, mocker): client = Bfabric.connect_device_code( base_url="https://example.com/bfabric///", + client_id="my-cli", + scope=_TEST_SCOPE, ) assert client.config.base_url == "https://example.com/bfabric/" diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_register.py b/tests/bfabric_scripts/cli/login/test_cmd_login_register.py index 599eec42..e4fdb8f1 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_register.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_register.py @@ -5,7 +5,7 @@ import pytest import yaml -from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_OAUTH_SCOPE from bfabric_scripts.cli.login.register import cmd_login_register From ee820d74237b3defa513eaba6d699fcd6415bf6e Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Wed, 15 Jul 2026 14:57:50 +0200 Subject: [PATCH 07/29] docs(oauth): align connect() error hint with renamed 'auth login' command --- bfabric/src/bfabric/bfabric.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bfabric/src/bfabric/bfabric.py b/bfabric/src/bfabric/bfabric.py index 87339745..260d38c6 100644 --- a/bfabric/src/bfabric/bfabric.py +++ b/bfabric/src/bfabric/bfabric.py @@ -130,7 +130,7 @@ def _connect_oauth_from_config(cls, config_data: ConfigData) -> Bfabric: if not config_data.client_id: raise ValueError( "OAuth config is missing 'client_id'. Set it in the config environment " - "(e.g. re-run 'bfabric-cli auth pkce' or 'bfabric-cli auth device-code')." + "(e.g. re-run 'bfabric-cli auth login' or 'bfabric-cli auth device-code')." ) client_id = config_data.client_id env_name = config_data.env_name or "default" From 0de1c454d9f5b53cca4ae76580d37d4f8c64d286 Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Wed, 15 Jul 2026 15:03:14 +0200 Subject: [PATCH 08/29] Update oauth_integration.md --- bfabric/docs/design/oauth_integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bfabric/docs/design/oauth_integration.md b/bfabric/docs/design/oauth_integration.md index 499ec630..efa8d125 100644 --- a/bfabric/docs/design/oauth_integration.md +++ b/bfabric/docs/design/oauth_integration.md @@ -22,7 +22,7 @@ Private module under `bfabric/src/bfabric/_oauth/` implementing all OAuth primit | `url_token.py` | `UrlTokenContext` + `parse_url_token()` — extracts entity context (entity_id, application_id, etc.) from B-Fabric URL token JWTs. | | `webapp_client.py` | `WebappClient` — dual-identity client bundling a `user` (from URL token) and `service` (from client credentials) `Bfabric` instance. | -The core `_oauth` API is policy-free: `client_id` and `scope` are **required** arguments on all OAuth entry points — the library does not bake in a default client ID or scope. The CLI-facing defaults (`DEFAULT_CLIENT_ID = "CLI"`, `DEFAULT_OAUTH_SCOPE = "api:read api:write openid profile email groups"`) live with the CLI in `bfabric_scripts/.../cli/login/_constants.py`, where they belong as application policy. +The core `_oauth` API requires explicit `client_id` and `scope` arguments on all OAuth entry points. The library does not bake in a default client ID or scope. Tools like the CLI specify these values explicitly. --- From 7f15ffdbaf128c934a58c7ec15e612d625259e65 Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Wed, 15 Jul 2026 15:16:25 +0200 Subject: [PATCH 09/29] feat(auth): add use-case-oriented OAuth scope presets for CLI login Model login scope presets in cli/login/_constants.py as ScopePreset named tuples (name, scope, description), oriented by use case: read-only (api:read), read-write (api:write, which implies api:read), and upload (api:write tus). `auth login` / `auth device-code` now default to the read-write preset instead of the broad OIDC-inclusive scope. Client/webapp registration keeps DEFAULT_OAUTH_SCOPE (webapps need the OIDC claims). The presets + by-name index + default live in _constants.py for the interactive scope picker to consume as a follow-up. --- bfabric_scripts/docs/changelog.md | 2 + .../bfabric_scripts/cli/login/_constants.py | 37 ++++++++++++++++++- .../bfabric_scripts/cli/login/device_code.py | 4 +- .../src/bfabric_scripts/cli/login/pkce.py | 4 +- .../cli/login/test_login_constants.py | 30 +++++++++++++++ 5 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 tests/bfabric_scripts/cli/login/test_login_constants.py diff --git a/bfabric_scripts/docs/changelog.md b/bfabric_scripts/docs/changelog.md index 4150e26a..2d0a5484 100644 --- a/bfabric_scripts/docs/changelog.md +++ b/bfabric_scripts/docs/changelog.md @@ -10,6 +10,8 @@ Versioning currently follows `X.Y.Z` semantic versioning, independent of the `bf ## \[Unreleased\] +- `auth login` / `auth device-code` now request a minimal, use-case-oriented scope by default: read-write (`api:write`, which implies `api:read`) instead of the previous broad `api:read api:write openid profile email groups`. Named presets are available — `read-only` (`api:read`), `read-write` (`api:write`), `read-write-upload` (`api:write tus`) — and any scope can still be passed explicitly via `--scope`. Client/webapp registration (`auth register` / `auth register-webapp`) keeps the OIDC-inclusive default, which webapps need for user-identity claims. + ## \[1.16.0rc2\] - 2026-07-15 - `bfabric-cli auth` command group for OAuth authentication and client management: diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py index 540d6a6d..82d68dae 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py @@ -1,10 +1,43 @@ -"""Default OAuth client ID and scope for the CLI login commands. +"""Default OAuth client ID and scope policy for the CLI login commands. These are CLI-level policy: ``"CLI"`` is the client identity the CLI registers -as, and the scope string is the set of permissions the CLI requests. The core +as, and the scopes below are the permission sets the CLI requests. The core ``bfabric`` library deliberately does not bake in these defaults — its OAuth API requires callers to state ``client_id`` and ``scope`` explicitly. """ +from __future__ import annotations + +from typing import NamedTuple + DEFAULT_CLIENT_ID = "CLI" + +# Scope requested when *registering* an OAuth client/webapp (``auth register`` / +# ``auth register-webapp``). Broad and OIDC-inclusive on purpose: a registered webapp +# needs ``openid profile email groups`` for the user-identity claims it reads from the +# URL-token exchange, which the login presets below intentionally omit. DEFAULT_OAUTH_SCOPE = "api:read api:write openid profile email groups" + + +class ScopePreset(NamedTuple): + """A named, use-case-oriented OAuth scope set offered at interactive login.""" + + name: str + scope: str + description: str + + +# Scope presets for interactive *login* (``auth login`` / ``auth device-code``), ordered +# by increasing capability. ``api:write`` implies ``api:read`` server-side, so the +# read-write set lists only ``api:write``; ``tus`` adds file-upload permission on top. +SCOPE_PRESETS: tuple[ScopePreset, ...] = ( + ScopePreset("read-only", "api:read", "Read from the API"), + ScopePreset("read-write", "api:write", "Write to the API (includes read from the API)"), + ScopePreset("upload", "api:write tus", "Upload files to the API (includes read and write API)"), +) + +SCOPE_PRESETS_BY_NAME: dict[str, ScopePreset] = {preset.name: preset for preset in SCOPE_PRESETS} + +# The preset requested by default when the user does not pick one. +DEFAULT_SCOPE_PRESET = "read-write" +DEFAULT_LOGIN_SCOPE = SCOPE_PRESETS_BY_NAME[DEFAULT_SCOPE_PRESET].scope diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py index c7478feb..c7eb57b4 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py @@ -12,7 +12,7 @@ from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_LOGIN_SCOPE def cmd_login_device_code( @@ -21,7 +21,7 @@ def cmd_login_device_code( client_id: Annotated[str, cyclopts.Parameter(help="OAuth client ID.")] = DEFAULT_CLIENT_ID, config_env: Annotated[str, cyclopts.Parameter(help="Environment name in the config file.")] = "PRODUCTION", config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_OAUTH_SCOPE, + scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_LOGIN_SCOPE, timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for authorization.")] = 600.0, set_default: Annotated[ bool, cyclopts.Parameter(help="Set this environment as the default in the config file.") diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py index 7601313e..0678c3d8 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py @@ -12,7 +12,7 @@ from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_LOGIN_SCOPE def cmd_auth_login( @@ -21,7 +21,7 @@ def cmd_auth_login( client_id: Annotated[str, cyclopts.Parameter(help="OAuth client ID.")] = DEFAULT_CLIENT_ID, config_env: Annotated[str, cyclopts.Parameter(help="Environment name in the config file.")] = "PRODUCTION", config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_OAUTH_SCOPE, + scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_LOGIN_SCOPE, port: Annotated[int, cyclopts.Parameter(help="Local port for callback (0 = auto).")] = 0, timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for login.")] = 120.0, set_default: Annotated[ diff --git a/tests/bfabric_scripts/cli/login/test_login_constants.py b/tests/bfabric_scripts/cli/login/test_login_constants.py new file mode 100644 index 00000000..17d07310 --- /dev/null +++ b/tests/bfabric_scripts/cli/login/test_login_constants.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from bfabric_scripts.cli.login._constants import ( + DEFAULT_LOGIN_SCOPE, + DEFAULT_SCOPE_PRESET, + SCOPE_PRESETS, + SCOPE_PRESETS_BY_NAME, +) + + +class TestScopePresets: + def test_presets_are_minimal_use_case_sets(self): + # Login presets deliberately omit the OIDC/groups scopes (those live in + # DEFAULT_OAUTH_SCOPE for registration), and api:write implies api:read + # server-side so read-write lists only api:write. + assert [(p.name, p.scope) for p in SCOPE_PRESETS] == [ + ("read-only", "api:read"), + ("read-write", "api:write"), + ("upload", "api:write tus"), + ] + + def test_every_preset_has_a_description(self): + assert all(preset.description for preset in SCOPE_PRESETS) + + def test_by_name_index_covers_all_presets(self): + assert set(SCOPE_PRESETS_BY_NAME) == {preset.name for preset in SCOPE_PRESETS} + + def test_default_login_scope_is_read_write(self): + assert DEFAULT_SCOPE_PRESET == "read-write" + assert DEFAULT_LOGIN_SCOPE == SCOPE_PRESETS_BY_NAME["read-write"].scope == "api:write" From 58d4de100beb51491ca16f00dc54baded525b7fe Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Wed, 15 Jul 2026 15:37:16 +0200 Subject: [PATCH 10/29] docs(oauth): reflect scope presets; rename DEFAULT_OAUTH_SCOPE The CLI login default is now the read-write preset (api:write), not the broad OIDC scope, so the troubleshooting doc no longer claims groups is in the default (employees must request it explicitly for file access). Rename the CLI registration-scope constant DEFAULT_OAUTH_SCOPE -> DEFAULT_REGISTRATION_SCOPE to say what it is, and fix a stale comment in transfer/tokens.py that referenced the removed core constant. --- .../design/oauth_usage_and_troubleshooting.md | 16 ++++++++++------ bfabric/src/bfabric/transfer/tokens.py | 3 ++- bfabric_scripts/example/test_webapp_flow.py | 6 +++--- .../src/bfabric_scripts/cli/login/_constants.py | 2 +- .../src/bfabric_scripts/cli/login/register.py | 4 ++-- .../bfabric_scripts/cli/login/register_webapp.py | 4 ++-- .../cli/login/test_cmd_login_register.py | 6 +++--- .../cli/login/test_login_constants.py | 2 +- 8 files changed, 24 insertions(+), 19 deletions(-) diff --git a/bfabric/docs/design/oauth_usage_and_troubleshooting.md b/bfabric/docs/design/oauth_usage_and_troubleshooting.md index c3c1a958..ead77ee6 100644 --- a/bfabric/docs/design/oauth_usage_and_troubleshooting.md +++ b/bfabric/docs/design/oauth_usage_and_troubleshooting.md @@ -76,9 +76,13 @@ like `containers`.** If a resource server authorizes by *your* container members token won't do — you need a user flow (PKCE or device code). The core `Bfabric` OAuth methods take `client_id` and `scope` as **required** arguments — there is -no baked-in default. The `bfabric-cli` login commands default the scope to -`"api:read api:write openid profile email groups"` — it **includes `groups`** (the employee -file-access path, see below) but not `containers` or `download`. +no baked-in default. The `bfabric-cli` login commands (`auth login` / `auth device-code`) default to +the **`read-write`** scope preset (`api:write`, which implies `api:read`); `read-only` (`api:read`) +and `upload` (`api:write tus`) presets are also available, and any scope can be passed with `--scope`. +These login presets are **minimal API scopes** — they do **not** include `groups` (the employee +file-access path, see below) or the OIDC scopes, so request those explicitly when you need them. +Client/webapp registration (`auth register` / `auth register-webapp`) keeps the broader +OIDC-inclusive default. --- @@ -94,9 +98,9 @@ File/download access is authorized by **container membership**, which a token ca > **Employees: request the `groups` scope — this is the practical PKCE path.** > The **`containers` scope cannot be requested via PKCE** (it is restricted; the server silently > drops it — which is exactly why requesting `containers` in an interactive flow yields a token -> *without* the claim and no error). **`groups`, however, is requestable and is in the CLI's -> default scope.** So an employee gets file access from a normal user flow as long as -> `groups` is in the requested scope: +> *without* the claim and no error). **`groups`, however, is requestable.** It is **not** part of +> the CLI's login presets (those are minimal API scopes), so pass it explicitly to get file access +> from a normal user flow: > > ```python > client = Bfabric.connect_pkce( diff --git a/bfabric/src/bfabric/transfer/tokens.py b/bfabric/src/bfabric/transfer/tokens.py index 0264c0eb..6b193d50 100644 --- a/bfabric/src/bfabric/transfer/tokens.py +++ b/bfabric/src/bfabric/transfer/tokens.py @@ -25,7 +25,8 @@ from bfabric import Bfabric from bfabric.config import BfabricAuth -# Scopes the CLI is pre-registered for but that DEFAULT_OAUTH_SCOPE does not request by default. +# Scopes the CLI is pre-registered for but that the default login presets do not request; the +# ``{scope}`` placeholder is filled with whatever extra scope the failed operation was missing. _PKCE_SCOPE_HINT = 'bfabric-cli auth login --scope "api:read api:write openid profile email groups {scope}"' diff --git a/bfabric_scripts/example/test_webapp_flow.py b/bfabric_scripts/example/test_webapp_flow.py index d4f5f008..0465acc6 100644 --- a/bfabric_scripts/example/test_webapp_flow.py +++ b/bfabric_scripts/example/test_webapp_flow.py @@ -26,7 +26,7 @@ from bfabric._oauth.registration import register_webapp from bfabric._oauth.webapp_client import WebappClient from bfabric.entities.core.uri import EntityUri -from bfabric_scripts.cli.login._constants import DEFAULT_OAUTH_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_REGISTRATION_SCOPE PORT = 19876 REDIRECT_PATH = "/callback" @@ -57,7 +57,7 @@ def main() -> None: app_name=app_name, web_url=web_url, service_user="itfeeder", - scope=DEFAULT_OAUTH_SCOPE, + scope=DEFAULT_REGISTRATION_SCOPE, hidden=True, ) oauth_info = result["oauth"] @@ -151,7 +151,7 @@ def serve() -> None: launch_token=jwt, client_id=oauth_client_id, client_secret=oauth_client_secret, - scope=DEFAULT_OAUTH_SCOPE, + scope=DEFAULT_REGISTRATION_SCOPE, ) except Exception as e: print(f"\nERROR: Token exchange failed: {e}", file=sys.stderr) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py index 82d68dae..0bb9e6c4 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py @@ -16,7 +16,7 @@ # ``auth register-webapp``). Broad and OIDC-inclusive on purpose: a registered webapp # needs ``openid profile email groups`` for the user-identity claims it reads from the # URL-token exchange, which the login presets below intentionally omit. -DEFAULT_OAUTH_SCOPE = "api:read api:write openid profile email groups" +DEFAULT_REGISTRATION_SCOPE = "api:read api:write openid profile email groups" class ScopePreset(NamedTuple): diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/register.py b/bfabric_scripts/src/bfabric_scripts/cli/login/register.py index 2c28728f..e332d672 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/register.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/register.py @@ -12,7 +12,7 @@ from bfabric._oauth.registration import register_client from bfabric.config import DEFAULT_CONFIG_FILE -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_REGISTRATION_SCOPE def _resolve_token_from_config(config_env: str, config_file: Path) -> tuple[str, str]: @@ -79,7 +79,7 @@ def cmd_login_register( service_user: Annotated[ str | None, cyclopts.Parameter(help="Service user login (enables client_credentials grant).") ] = None, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_OAUTH_SCOPE, + scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_REGISTRATION_SCOPE, grant_types: Annotated[ list[str] | None, cyclopts.Parameter(help="Grant types to request (overrides default webapp grants)."), diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py b/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py index 78cf599b..0d623764 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py @@ -10,7 +10,7 @@ import cyclopts from bfabric.config import DEFAULT_CONFIG_FILE -from bfabric_scripts.cli.login._constants import DEFAULT_OAUTH_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_REGISTRATION_SCOPE def cmd_login_register_webapp( @@ -22,7 +22,7 @@ def cmd_login_register_webapp( service_user: Annotated[ str | None, cyclopts.Parameter(help="Service user login (enables client_credentials grant).") ] = None, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_OAUTH_SCOPE, + scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_REGISTRATION_SCOPE, application_id: Annotated[ int | None, cyclopts.Parameter(help="Existing application ID to update (omit to create new).") ] = None, diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_register.py b/tests/bfabric_scripts/cli/login/test_cmd_login_register.py index e4fdb8f1..a170230d 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_register.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_register.py @@ -5,7 +5,7 @@ import pytest import yaml -from bfabric_scripts.cli.login._constants import DEFAULT_OAUTH_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_REGISTRATION_SCOPE from bfabric_scripts.cli.login.register import cmd_login_register @@ -107,7 +107,7 @@ def test_uses_cached_token_from_config_env(self, tmp_path, mocker, capsys): client_name="My App", redirect_uri="http://localhost/callback", service_user=None, - scope=DEFAULT_OAUTH_SCOPE, + scope=DEFAULT_REGISTRATION_SCOPE, grant_types=None, ) output = capsys.readouterr() @@ -154,7 +154,7 @@ def test_explicit_base_url_overrides_config(self, tmp_path, mocker, capsys): client_name="My App", redirect_uri="http://localhost/callback", service_user=None, - scope=DEFAULT_OAUTH_SCOPE, + scope=DEFAULT_REGISTRATION_SCOPE, grant_types=None, ) diff --git a/tests/bfabric_scripts/cli/login/test_login_constants.py b/tests/bfabric_scripts/cli/login/test_login_constants.py index 17d07310..45c061ae 100644 --- a/tests/bfabric_scripts/cli/login/test_login_constants.py +++ b/tests/bfabric_scripts/cli/login/test_login_constants.py @@ -11,7 +11,7 @@ class TestScopePresets: def test_presets_are_minimal_use_case_sets(self): # Login presets deliberately omit the OIDC/groups scopes (those live in - # DEFAULT_OAUTH_SCOPE for registration), and api:write implies api:read + # DEFAULT_REGISTRATION_SCOPE for registration), and api:write implies api:read # server-side so read-write lists only api:write. assert [(p.name, p.scope) for p in SCOPE_PRESETS] == [ ("read-only", "api:read"), From 48cd27678bd31cab5371fbda8021f01dab9b5d3f Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Wed, 15 Jul 2026 15:43:09 +0200 Subject: [PATCH 11/29] docs(oauth): simplify upload/re-auth scope hints to api:write + missing scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-auth hint (_PKCE_SCOPE_HINT) and the upload examples requested the full OIDC scope set, which the operation never needs. Reduce them to "api:write {scope}" (api:write implies api:read) — for upload that is exactly the `upload` preset (api:write tus). Also fix the changelog preset name (upload, not read-write-upload). --- bfabric/docs/user_guides/bfabric-cli/workunits.md | 2 +- bfabric/src/bfabric/examples/prove_tus_resume.py | 2 +- bfabric/src/bfabric/transfer/tokens.py | 7 ++++--- bfabric_scripts/docs/changelog.md | 2 +- bfabric_scripts/src/bfabric_scripts/cli/workunit/upload.py | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/bfabric/docs/user_guides/bfabric-cli/workunits.md b/bfabric/docs/user_guides/bfabric-cli/workunits.md index b539cc5d..97e0ba80 100644 --- a/bfabric/docs/user_guides/bfabric-cli/workunits.md +++ b/bfabric/docs/user_guides/bfabric-cli/workunits.md @@ -147,7 +147,7 @@ carries the `tus` scope. Install the extra and authenticate once: ```bash pip install 'bfabric[transfer]' -bfabric-cli auth login --scope "api:read api:write openid profile email groups tus" +bfabric-cli auth login --scope "api:write tus" ``` ### Basic Usage diff --git a/bfabric/src/bfabric/examples/prove_tus_resume.py b/bfabric/src/bfabric/examples/prove_tus_resume.py index 66feba16..583b0e43 100644 --- a/bfabric/src/bfabric/examples/prove_tus_resume.py +++ b/bfabric/src/bfabric/examples/prove_tus_resume.py @@ -25,7 +25,7 @@ Prerequisites: an OAuth-backed config env with the ``tus`` scope. Authenticate once with:: - bfabric-cli auth login --scope "api:read api:write openid profile email groups tus" + bfabric-cli auth login --scope "api:write tus" then run like the equivalent CLI upload:: diff --git a/bfabric/src/bfabric/transfer/tokens.py b/bfabric/src/bfabric/transfer/tokens.py index 6b193d50..c4f0bdc7 100644 --- a/bfabric/src/bfabric/transfer/tokens.py +++ b/bfabric/src/bfabric/transfer/tokens.py @@ -25,9 +25,10 @@ from bfabric import Bfabric from bfabric.config import BfabricAuth -# Scopes the CLI is pre-registered for but that the default login presets do not request; the -# ``{scope}`` placeholder is filled with whatever extra scope the failed operation was missing. -_PKCE_SCOPE_HINT = 'bfabric-cli auth login --scope "api:read api:write openid profile email groups {scope}"' +# Re-auth hint shown when a required transfer scope (``tus`` for upload, ``containers`` for download) +# is missing from the token: request read-write API access (``api:write`` implies ``api:read``) plus +# the missing scope. ``{scope}`` is filled with whatever the failed operation needed. +_PKCE_SCOPE_HINT = 'bfabric-cli auth login --scope "api:write {scope}"' def _safe_auth(client: Bfabric) -> BfabricAuth | None: diff --git a/bfabric_scripts/docs/changelog.md b/bfabric_scripts/docs/changelog.md index 2d0a5484..91589eac 100644 --- a/bfabric_scripts/docs/changelog.md +++ b/bfabric_scripts/docs/changelog.md @@ -10,7 +10,7 @@ Versioning currently follows `X.Y.Z` semantic versioning, independent of the `bf ## \[Unreleased\] -- `auth login` / `auth device-code` now request a minimal, use-case-oriented scope by default: read-write (`api:write`, which implies `api:read`) instead of the previous broad `api:read api:write openid profile email groups`. Named presets are available — `read-only` (`api:read`), `read-write` (`api:write`), `read-write-upload` (`api:write tus`) — and any scope can still be passed explicitly via `--scope`. Client/webapp registration (`auth register` / `auth register-webapp`) keeps the OIDC-inclusive default, which webapps need for user-identity claims. +- `auth login` / `auth device-code` now request a minimal, use-case-oriented scope by default: read-write (`api:write`, which implies `api:read`) instead of the previous broad `api:read api:write openid profile email groups`. Named presets are available — `read-only` (`api:read`), `read-write` (`api:write`), `upload` (`api:write tus`) — and any scope can still be passed explicitly via `--scope`. Client/webapp registration (`auth register` / `auth register-webapp`) keeps the OIDC-inclusive default, which webapps need for user-identity claims. ## \[1.16.0rc2\] - 2026-07-15 diff --git a/bfabric_scripts/src/bfabric_scripts/cli/workunit/upload.py b/bfabric_scripts/src/bfabric_scripts/cli/workunit/upload.py index 01132af2..31b632dd 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/workunit/upload.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/workunit/upload.py @@ -75,7 +75,7 @@ def cmd_workunit_upload(params: UploadParams, *, client: Bfabric) -> None: Creates a new workunit, or uploads into an existing one with ``--workunit-id``. Requires an OAuth-backed client with the ``tus`` scope; authenticate with - ``bfabric-cli auth login --scope "api:read api:write openid profile email groups tus"``. + ``bfabric-cli auth login --scope "api:write tus"``. """ with _upload_progress(enabled=_progress_enabled(requested=params.progress)) as reporter: summary = upload_files( From b4175b54231391e5c3c56a5915d77d8008b1a378 Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Wed, 15 Jul 2026 15:54:55 +0200 Subject: [PATCH 12/29] Revert scope-preset detour; keep #562 to the minimal core-defaults removal Reverts the CLI scope presets (7f15ffdb), the DEFAULT_OAUTH_SCOPE rename + troubleshooting-doc edits (58d4de10), and the upload/hint scope changes (48cd2767). #562 stays scoped to removing baked-in OAuth defaults from the core library; the CLI keeps its original DEFAULT_OAUTH_SCOPE / DEFAULT_CLIENT_ID and behavior unchanged. Scope presets belong in the interactive-picker follow-up. --- .../design/oauth_usage_and_troubleshooting.md | 16 +++----- .../docs/user_guides/bfabric-cli/workunits.md | 2 +- .../src/bfabric/examples/prove_tus_resume.py | 2 +- bfabric/src/bfabric/transfer/tokens.py | 6 +-- bfabric_scripts/docs/changelog.md | 2 - bfabric_scripts/example/test_webapp_flow.py | 6 +-- .../bfabric_scripts/cli/login/_constants.py | 39 ++----------------- .../bfabric_scripts/cli/login/device_code.py | 4 +- .../src/bfabric_scripts/cli/login/pkce.py | 4 +- .../src/bfabric_scripts/cli/login/register.py | 4 +- .../cli/login/register_webapp.py | 4 +- .../bfabric_scripts/cli/workunit/upload.py | 2 +- .../cli/login/test_cmd_login_register.py | 6 +-- .../cli/login/test_login_constants.py | 30 -------------- 14 files changed, 28 insertions(+), 99 deletions(-) delete mode 100644 tests/bfabric_scripts/cli/login/test_login_constants.py diff --git a/bfabric/docs/design/oauth_usage_and_troubleshooting.md b/bfabric/docs/design/oauth_usage_and_troubleshooting.md index ead77ee6..c3c1a958 100644 --- a/bfabric/docs/design/oauth_usage_and_troubleshooting.md +++ b/bfabric/docs/design/oauth_usage_and_troubleshooting.md @@ -76,13 +76,9 @@ like `containers`.** If a resource server authorizes by *your* container members token won't do — you need a user flow (PKCE or device code). The core `Bfabric` OAuth methods take `client_id` and `scope` as **required** arguments — there is -no baked-in default. The `bfabric-cli` login commands (`auth login` / `auth device-code`) default to -the **`read-write`** scope preset (`api:write`, which implies `api:read`); `read-only` (`api:read`) -and `upload` (`api:write tus`) presets are also available, and any scope can be passed with `--scope`. -These login presets are **minimal API scopes** — they do **not** include `groups` (the employee -file-access path, see below) or the OIDC scopes, so request those explicitly when you need them. -Client/webapp registration (`auth register` / `auth register-webapp`) keeps the broader -OIDC-inclusive default. +no baked-in default. The `bfabric-cli` login commands default the scope to +`"api:read api:write openid profile email groups"` — it **includes `groups`** (the employee +file-access path, see below) but not `containers` or `download`. --- @@ -98,9 +94,9 @@ File/download access is authorized by **container membership**, which a token ca > **Employees: request the `groups` scope — this is the practical PKCE path.** > The **`containers` scope cannot be requested via PKCE** (it is restricted; the server silently > drops it — which is exactly why requesting `containers` in an interactive flow yields a token -> *without* the claim and no error). **`groups`, however, is requestable.** It is **not** part of -> the CLI's login presets (those are minimal API scopes), so pass it explicitly to get file access -> from a normal user flow: +> *without* the claim and no error). **`groups`, however, is requestable and is in the CLI's +> default scope.** So an employee gets file access from a normal user flow as long as +> `groups` is in the requested scope: > > ```python > client = Bfabric.connect_pkce( diff --git a/bfabric/docs/user_guides/bfabric-cli/workunits.md b/bfabric/docs/user_guides/bfabric-cli/workunits.md index 97e0ba80..b539cc5d 100644 --- a/bfabric/docs/user_guides/bfabric-cli/workunits.md +++ b/bfabric/docs/user_guides/bfabric-cli/workunits.md @@ -147,7 +147,7 @@ carries the `tus` scope. Install the extra and authenticate once: ```bash pip install 'bfabric[transfer]' -bfabric-cli auth login --scope "api:write tus" +bfabric-cli auth login --scope "api:read api:write openid profile email groups tus" ``` ### Basic Usage diff --git a/bfabric/src/bfabric/examples/prove_tus_resume.py b/bfabric/src/bfabric/examples/prove_tus_resume.py index 583b0e43..66feba16 100644 --- a/bfabric/src/bfabric/examples/prove_tus_resume.py +++ b/bfabric/src/bfabric/examples/prove_tus_resume.py @@ -25,7 +25,7 @@ Prerequisites: an OAuth-backed config env with the ``tus`` scope. Authenticate once with:: - bfabric-cli auth login --scope "api:write tus" + bfabric-cli auth login --scope "api:read api:write openid profile email groups tus" then run like the equivalent CLI upload:: diff --git a/bfabric/src/bfabric/transfer/tokens.py b/bfabric/src/bfabric/transfer/tokens.py index c4f0bdc7..0264c0eb 100644 --- a/bfabric/src/bfabric/transfer/tokens.py +++ b/bfabric/src/bfabric/transfer/tokens.py @@ -25,10 +25,8 @@ from bfabric import Bfabric from bfabric.config import BfabricAuth -# Re-auth hint shown when a required transfer scope (``tus`` for upload, ``containers`` for download) -# is missing from the token: request read-write API access (``api:write`` implies ``api:read``) plus -# the missing scope. ``{scope}`` is filled with whatever the failed operation needed. -_PKCE_SCOPE_HINT = 'bfabric-cli auth login --scope "api:write {scope}"' +# Scopes the CLI is pre-registered for but that DEFAULT_OAUTH_SCOPE does not request by default. +_PKCE_SCOPE_HINT = 'bfabric-cli auth login --scope "api:read api:write openid profile email groups {scope}"' def _safe_auth(client: Bfabric) -> BfabricAuth | None: diff --git a/bfabric_scripts/docs/changelog.md b/bfabric_scripts/docs/changelog.md index 91589eac..4150e26a 100644 --- a/bfabric_scripts/docs/changelog.md +++ b/bfabric_scripts/docs/changelog.md @@ -10,8 +10,6 @@ Versioning currently follows `X.Y.Z` semantic versioning, independent of the `bf ## \[Unreleased\] -- `auth login` / `auth device-code` now request a minimal, use-case-oriented scope by default: read-write (`api:write`, which implies `api:read`) instead of the previous broad `api:read api:write openid profile email groups`. Named presets are available — `read-only` (`api:read`), `read-write` (`api:write`), `upload` (`api:write tus`) — and any scope can still be passed explicitly via `--scope`. Client/webapp registration (`auth register` / `auth register-webapp`) keeps the OIDC-inclusive default, which webapps need for user-identity claims. - ## \[1.16.0rc2\] - 2026-07-15 - `bfabric-cli auth` command group for OAuth authentication and client management: diff --git a/bfabric_scripts/example/test_webapp_flow.py b/bfabric_scripts/example/test_webapp_flow.py index 0465acc6..d4f5f008 100644 --- a/bfabric_scripts/example/test_webapp_flow.py +++ b/bfabric_scripts/example/test_webapp_flow.py @@ -26,7 +26,7 @@ from bfabric._oauth.registration import register_webapp from bfabric._oauth.webapp_client import WebappClient from bfabric.entities.core.uri import EntityUri -from bfabric_scripts.cli.login._constants import DEFAULT_REGISTRATION_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_OAUTH_SCOPE PORT = 19876 REDIRECT_PATH = "/callback" @@ -57,7 +57,7 @@ def main() -> None: app_name=app_name, web_url=web_url, service_user="itfeeder", - scope=DEFAULT_REGISTRATION_SCOPE, + scope=DEFAULT_OAUTH_SCOPE, hidden=True, ) oauth_info = result["oauth"] @@ -151,7 +151,7 @@ def serve() -> None: launch_token=jwt, client_id=oauth_client_id, client_secret=oauth_client_secret, - scope=DEFAULT_REGISTRATION_SCOPE, + scope=DEFAULT_OAUTH_SCOPE, ) except Exception as e: print(f"\nERROR: Token exchange failed: {e}", file=sys.stderr) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py index 0bb9e6c4..540d6a6d 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py @@ -1,43 +1,10 @@ -"""Default OAuth client ID and scope policy for the CLI login commands. +"""Default OAuth client ID and scope for the CLI login commands. These are CLI-level policy: ``"CLI"`` is the client identity the CLI registers -as, and the scopes below are the permission sets the CLI requests. The core +as, and the scope string is the set of permissions the CLI requests. The core ``bfabric`` library deliberately does not bake in these defaults — its OAuth API requires callers to state ``client_id`` and ``scope`` explicitly. """ -from __future__ import annotations - -from typing import NamedTuple - DEFAULT_CLIENT_ID = "CLI" - -# Scope requested when *registering* an OAuth client/webapp (``auth register`` / -# ``auth register-webapp``). Broad and OIDC-inclusive on purpose: a registered webapp -# needs ``openid profile email groups`` for the user-identity claims it reads from the -# URL-token exchange, which the login presets below intentionally omit. -DEFAULT_REGISTRATION_SCOPE = "api:read api:write openid profile email groups" - - -class ScopePreset(NamedTuple): - """A named, use-case-oriented OAuth scope set offered at interactive login.""" - - name: str - scope: str - description: str - - -# Scope presets for interactive *login* (``auth login`` / ``auth device-code``), ordered -# by increasing capability. ``api:write`` implies ``api:read`` server-side, so the -# read-write set lists only ``api:write``; ``tus`` adds file-upload permission on top. -SCOPE_PRESETS: tuple[ScopePreset, ...] = ( - ScopePreset("read-only", "api:read", "Read from the API"), - ScopePreset("read-write", "api:write", "Write to the API (includes read from the API)"), - ScopePreset("upload", "api:write tus", "Upload files to the API (includes read and write API)"), -) - -SCOPE_PRESETS_BY_NAME: dict[str, ScopePreset] = {preset.name: preset for preset in SCOPE_PRESETS} - -# The preset requested by default when the user does not pick one. -DEFAULT_SCOPE_PRESET = "read-write" -DEFAULT_LOGIN_SCOPE = SCOPE_PRESETS_BY_NAME[DEFAULT_SCOPE_PRESET].scope +DEFAULT_OAUTH_SCOPE = "api:read api:write openid profile email groups" diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py index c7eb57b4..c7478feb 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py @@ -12,7 +12,7 @@ from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_LOGIN_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE def cmd_login_device_code( @@ -21,7 +21,7 @@ def cmd_login_device_code( client_id: Annotated[str, cyclopts.Parameter(help="OAuth client ID.")] = DEFAULT_CLIENT_ID, config_env: Annotated[str, cyclopts.Parameter(help="Environment name in the config file.")] = "PRODUCTION", config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_LOGIN_SCOPE, + scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_OAUTH_SCOPE, timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for authorization.")] = 600.0, set_default: Annotated[ bool, cyclopts.Parameter(help="Set this environment as the default in the config file.") diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py index 0678c3d8..7601313e 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py @@ -12,7 +12,7 @@ from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_LOGIN_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE def cmd_auth_login( @@ -21,7 +21,7 @@ def cmd_auth_login( client_id: Annotated[str, cyclopts.Parameter(help="OAuth client ID.")] = DEFAULT_CLIENT_ID, config_env: Annotated[str, cyclopts.Parameter(help="Environment name in the config file.")] = "PRODUCTION", config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_LOGIN_SCOPE, + scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_OAUTH_SCOPE, port: Annotated[int, cyclopts.Parameter(help="Local port for callback (0 = auto).")] = 0, timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for login.")] = 120.0, set_default: Annotated[ diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/register.py b/bfabric_scripts/src/bfabric_scripts/cli/login/register.py index e332d672..2c28728f 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/register.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/register.py @@ -12,7 +12,7 @@ from bfabric._oauth.registration import register_client from bfabric.config import DEFAULT_CONFIG_FILE -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_REGISTRATION_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE def _resolve_token_from_config(config_env: str, config_file: Path) -> tuple[str, str]: @@ -79,7 +79,7 @@ def cmd_login_register( service_user: Annotated[ str | None, cyclopts.Parameter(help="Service user login (enables client_credentials grant).") ] = None, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_REGISTRATION_SCOPE, + scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_OAUTH_SCOPE, grant_types: Annotated[ list[str] | None, cyclopts.Parameter(help="Grant types to request (overrides default webapp grants)."), diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py b/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py index 0d623764..78cf599b 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py @@ -10,7 +10,7 @@ import cyclopts from bfabric.config import DEFAULT_CONFIG_FILE -from bfabric_scripts.cli.login._constants import DEFAULT_REGISTRATION_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_OAUTH_SCOPE def cmd_login_register_webapp( @@ -22,7 +22,7 @@ def cmd_login_register_webapp( service_user: Annotated[ str | None, cyclopts.Parameter(help="Service user login (enables client_credentials grant).") ] = None, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_REGISTRATION_SCOPE, + scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_OAUTH_SCOPE, application_id: Annotated[ int | None, cyclopts.Parameter(help="Existing application ID to update (omit to create new).") ] = None, diff --git a/bfabric_scripts/src/bfabric_scripts/cli/workunit/upload.py b/bfabric_scripts/src/bfabric_scripts/cli/workunit/upload.py index 31b632dd..01132af2 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/workunit/upload.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/workunit/upload.py @@ -75,7 +75,7 @@ def cmd_workunit_upload(params: UploadParams, *, client: Bfabric) -> None: Creates a new workunit, or uploads into an existing one with ``--workunit-id``. Requires an OAuth-backed client with the ``tus`` scope; authenticate with - ``bfabric-cli auth login --scope "api:write tus"``. + ``bfabric-cli auth login --scope "api:read api:write openid profile email groups tus"``. """ with _upload_progress(enabled=_progress_enabled(requested=params.progress)) as reporter: summary = upload_files( diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_register.py b/tests/bfabric_scripts/cli/login/test_cmd_login_register.py index a170230d..e4fdb8f1 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_register.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_register.py @@ -5,7 +5,7 @@ import pytest import yaml -from bfabric_scripts.cli.login._constants import DEFAULT_REGISTRATION_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_OAUTH_SCOPE from bfabric_scripts.cli.login.register import cmd_login_register @@ -107,7 +107,7 @@ def test_uses_cached_token_from_config_env(self, tmp_path, mocker, capsys): client_name="My App", redirect_uri="http://localhost/callback", service_user=None, - scope=DEFAULT_REGISTRATION_SCOPE, + scope=DEFAULT_OAUTH_SCOPE, grant_types=None, ) output = capsys.readouterr() @@ -154,7 +154,7 @@ def test_explicit_base_url_overrides_config(self, tmp_path, mocker, capsys): client_name="My App", redirect_uri="http://localhost/callback", service_user=None, - scope=DEFAULT_REGISTRATION_SCOPE, + scope=DEFAULT_OAUTH_SCOPE, grant_types=None, ) diff --git a/tests/bfabric_scripts/cli/login/test_login_constants.py b/tests/bfabric_scripts/cli/login/test_login_constants.py deleted file mode 100644 index 45c061ae..00000000 --- a/tests/bfabric_scripts/cli/login/test_login_constants.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from bfabric_scripts.cli.login._constants import ( - DEFAULT_LOGIN_SCOPE, - DEFAULT_SCOPE_PRESET, - SCOPE_PRESETS, - SCOPE_PRESETS_BY_NAME, -) - - -class TestScopePresets: - def test_presets_are_minimal_use_case_sets(self): - # Login presets deliberately omit the OIDC/groups scopes (those live in - # DEFAULT_REGISTRATION_SCOPE for registration), and api:write implies api:read - # server-side so read-write lists only api:write. - assert [(p.name, p.scope) for p in SCOPE_PRESETS] == [ - ("read-only", "api:read"), - ("read-write", "api:write"), - ("upload", "api:write tus"), - ] - - def test_every_preset_has_a_description(self): - assert all(preset.description for preset in SCOPE_PRESETS) - - def test_by_name_index_covers_all_presets(self): - assert set(SCOPE_PRESETS_BY_NAME) == {preset.name for preset in SCOPE_PRESETS} - - def test_default_login_scope_is_read_write(self): - assert DEFAULT_SCOPE_PRESET == "read-write" - assert DEFAULT_LOGIN_SCOPE == SCOPE_PRESETS_BY_NAME["read-write"].scope == "api:write" From 956651b05e3818d9b155e952b3ee1933c6e0bdfa Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Wed, 15 Jul 2026 16:33:39 +0200 Subject: [PATCH 13/29] fix(bfabric_scripts): abort auth login when the "set as default" prompt is cancelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl-C at the interactive "Set '' as the default environment?" prompt made questionary's .ask() print "Cancelled by user" and return None; confirm() collapsed that None into False, so login silently proceeded as if --no-set-default were given instead of aborting. confirm() now surfaces cancellation as None (consistent with the other interactive wrappers), resolve_set_default propagates it, and the pkce / device-code / pat login commands abort with "Login aborted." before touching the OAuth flow or the config. Adds regression tests for all three commands. 🤖 Prepared with assistance from Claude Opus 4.8 via Claude Code. --- bfabric_scripts/docs/changelog.md | 2 ++ .../src/bfabric_scripts/cli/interactive.py | 13 +++++++------ .../src/bfabric_scripts/cli/login/_common.py | 4 ++-- .../bfabric_scripts/cli/login/device_code.py | 3 +++ .../src/bfabric_scripts/cli/login/pat.py | 3 +++ .../src/bfabric_scripts/cli/login/pkce.py | 3 +++ .../cli/login/test_cmd_login_device_code.py | 18 ++++++++++++++++++ .../cli/login/test_cmd_login_pat.py | 17 +++++++++++++++++ .../cli/login/test_cmd_login_pkce.py | 18 ++++++++++++++++++ .../cli/login/test_login_common.py | 6 ++++++ tests/bfabric_scripts/cli/test_interactive.py | 7 ++++--- 11 files changed, 83 insertions(+), 11 deletions(-) diff --git a/bfabric_scripts/docs/changelog.md b/bfabric_scripts/docs/changelog.md index 965abe02..99aeee77 100644 --- a/bfabric_scripts/docs/changelog.md +++ b/bfabric_scripts/docs/changelog.md @@ -10,6 +10,8 @@ Versioning currently follows `X.Y.Z` semantic versioning, independent of the `bf ## \[Unreleased\] +- `auth pkce` / `auth device-code` / `auth pat`: cancelling the interactive "set as default?" prompt (Ctrl-C) now aborts the login instead of silently proceeding as if `--no-set-default` were given. + ## \[1.16.0rc2\] - 2026-07-15 - `bfabric-cli auth` command group for OAuth authentication and client management: diff --git a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py index 82404942..118d75a8 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py @@ -74,14 +74,15 @@ def text_input(message: str, *, default: str = "") -> str | None: return answer or None -def confirm(message: str, *, default: bool = False) -> bool: - """Ask a yes/no question, returning the answer as a bool. +def confirm(message: str, *, default: bool = False) -> bool | None: + """Ask a yes/no question. - A cancel (Ctrl-C) yields ``None`` from questionary, which is treated as ``False`` -- - the safe answer for the destructive prompts this guards. + Returns ``True``/``False`` for an explicit answer, or ``None`` if the user cancels (Ctrl-C) -- + questionary prints its own cancellation notice and yields ``None``. Like the other wrappers here, + cancellation surfaces as ``None`` so callers can tell an aborted prompt apart from a declined + "no" (a caller that wants to treat both alike can just check falsiness). """ - answer = cast("bool | None", questionary.confirm(message, default=default).ask()) - return answer is True + return cast("bool | None", questionary.confirm(message, default=default).ask()) def resolve_choice( diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py index 3eb9d4eb..e3870c36 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py @@ -99,12 +99,12 @@ def resolve_scope(scope: str | None) -> str | None: return _SCOPE_PRESETS[picked] -def resolve_set_default(set_default: bool | None, config_env: str) -> bool: +def resolve_set_default(set_default: bool | None, config_env: str) -> bool | None: """Resolve whether the freshly-authenticated environment becomes the config default. * explicit ``--set-default`` / ``--no-set-default`` (i.e. not ``None``) -> honored verbatim. * otherwise, no TTY -> ``True`` (the historical default). - * otherwise -> a yes/no prompt, preselected yes. + * otherwise -> a yes/no prompt, preselected yes; ``None`` if the user cancels (the caller aborts). """ if set_default is not None: return set_default diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py index ba07ed11..fc50dccc 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py @@ -46,6 +46,9 @@ def cmd_login_device_code( print("Login aborted.", file=sys.stderr) return set_default = resolve_set_default(set_default, config_env) + if set_default is None: + print("Login aborted.", file=sys.stderr) + return base_url = base_url.rstrip("/") try: diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py b/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py index f5cc4ab2..a124b631 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py @@ -33,6 +33,9 @@ def cmd_login_pat( print("Login aborted.", file=sys.stderr) return set_default = resolve_set_default(set_default, config_env) + if set_default is None: + print("Login aborted.", file=sys.stderr) + return if pat is None: pat = getpass.getpass("Personal Access Token: ") diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py index f04d2fb7..e135f80d 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py @@ -47,6 +47,9 @@ def cmd_login_pkce( print("Login aborted.", file=sys.stderr) return set_default = resolve_set_default(set_default, config_env) + if set_default is None: + print("Login aborted.", file=sys.stderr) + return base_url = base_url.rstrip("/") print("Opening browser for authentication...", file=sys.stderr) diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py b/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py index e767da87..276e57ca 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py @@ -38,6 +38,24 @@ def test_set_default_false_does_not_set_default(self, tmp_path, mocker, oauth_to assert "default_config" not in data["GENERAL"] assert data["PROD"]["auth_method"] == "oauth" + def test_cancel_at_set_default_aborts(self, tmp_path, mocker, capsys): + config_file = tmp_path / "config.yml" + mock_dc = mocker.patch("bfabric_scripts.cli.login.device_code.device_code_login") + # No --set-default given: the user reaches the confirm prompt and cancels it (Ctrl-C -> None). + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) + mocker.patch("bfabric_scripts.cli.login._common.confirm", return_value=None) + cmd_login_device_code( + base_url="https://example.com/bfabric", + client_id="test-client", + config_env="PROD", + config_file=config_file, + scope="read-write", + ) + # Cancelling aborts the whole login: no device-code flow, no config written. + mock_dc.assert_not_called() + assert not config_file.exists() + assert "Login aborted." in capsys.readouterr().err + def test_scope_preset_is_expanded(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" mock_dc = mocker.patch("bfabric_scripts.cli.login.device_code.device_code_login", return_value=oauth_token) diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_pat.py b/tests/bfabric_scripts/cli/login/test_cmd_login_pat.py index 8f138114..fb7d9d70 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_pat.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_pat.py @@ -50,6 +50,23 @@ def test_prompts_when_pat_omitted(self, tmp_path, mocker): data = yaml.safe_load(config_file.read_text()) assert data["PROD"]["pat"] == "prompted-token" + def test_cancel_at_set_default_aborts(self, tmp_path, mocker, capsys): + config_file = tmp_path / "config.yml" + getpass = mocker.patch("bfabric_scripts.cli.login.pat.getpass.getpass") + # No --set-default given: the user reaches the confirm prompt and cancels it (Ctrl-C -> None). + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) + mocker.patch("bfabric_scripts.cli.login._common.confirm", return_value=None) + cmd_login_pat( + base_url="https://example.com/bfabric", + pat="tok", + config_env="PROD", + config_file=config_file, + ) + # Cancelling aborts before any secret prompt and writes no config. + getpass.assert_not_called() + assert not config_file.exists() + assert "Login aborted." in capsys.readouterr().err + def test_set_default_false_does_not_set_default(self, tmp_path): config_file = tmp_path / "config.yml" cmd_login_pat( diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py b/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py index df5d9aba..f3e27101 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_pkce.py @@ -57,6 +57,24 @@ def test_prompts_for_default_when_omitted(self, tmp_path, mocker, oauth_token, o assert "default_config" not in data["GENERAL"] assert data["PROD"]["auth_method"] == "oauth" + def test_cancel_at_set_default_aborts(self, tmp_path, mocker, capsys): + config_file = tmp_path / "config.yml" + mock_pkce = mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login") + # No --set-default given: the user reaches the confirm prompt and cancels it (Ctrl-C -> None). + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) + mocker.patch("bfabric_scripts.cli.login._common.confirm", return_value=None) + cmd_login_pkce( + base_url="https://example.com/bfabric", + client_id="test-client", + config_env="PROD", + config_file=config_file, + scope="read-write", + ) + # Cancelling aborts the whole login: no browser flow, no config written. + mock_pkce.assert_not_called() + assert not config_file.exists() + assert "Login aborted." in capsys.readouterr().err + def test_scope_preset_is_expanded(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" mock_pkce = mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=oauth_token) diff --git a/tests/bfabric_scripts/cli/login/test_login_common.py b/tests/bfabric_scripts/cli/login/test_login_common.py index a040dab5..ebdd163e 100644 --- a/tests/bfabric_scripts/cli/login/test_login_common.py +++ b/tests/bfabric_scripts/cli/login/test_login_common.py @@ -159,3 +159,9 @@ def test_interactive_prompts_preselected_yes(self, mocker): assert resolve_set_default(None, "PROD") is False # The prompt is preselected to "yes". assert confirm.call_args.kwargs["default"] is True + + def test_interactive_cancel_returns_none(self, mocker): + # A cancelled prompt (confirm -> None) is propagated so the caller aborts the login. + mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) + mocker.patch("bfabric_scripts.cli.login._common.confirm", return_value=None) + assert resolve_set_default(None, "PROD") is None diff --git a/tests/bfabric_scripts/cli/test_interactive.py b/tests/bfabric_scripts/cli/test_interactive.py index 25f31319..fa5555e3 100644 --- a/tests/bfabric_scripts/cli/test_interactive.py +++ b/tests/bfabric_scripts/cli/test_interactive.py @@ -96,12 +96,13 @@ def test_returns_false_when_declined(self, mocker): mocker.patch("bfabric_scripts.cli.interactive.questionary.confirm", return_value=question) assert confirm("Delete?") is False - def test_cancel_is_treated_as_false(self, mocker): - # Ctrl-C / Esc yields None; for a destructive prompt that must mean "no". + def test_cancel_returns_none(self, mocker): + # Ctrl-C / Esc yields None from questionary; confirm surfaces it so callers can tell an + # aborted prompt apart from an explicit "no". question = mocker.MagicMock() question.ask.return_value = None mocker.patch("bfabric_scripts.cli.interactive.questionary.confirm", return_value=question) - assert confirm("Delete?") is False + assert confirm("Delete?") is None class TestTextInput: From 817dacb5a63c821500744c2f82f9cf82d4efc220 Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Wed, 15 Jul 2026 16:58:08 +0200 Subject: [PATCH 14/29] docs(oauth): note minimal login presets; file access needs groups explicitly The CLI login presets (read-only / read-write / upload) are minimal API scopes and no longer include `groups` or the OIDC scopes. Update the OAuth troubleshooting doc, the transfer/tokens.py PKCE hint comment, and the bfabric_scripts changelog so employees know to request `groups` explicitly for file access. --- .../design/oauth_usage_and_troubleshooting.md | 16 ++++++++++------ bfabric/src/bfabric/transfer/tokens.py | 3 ++- bfabric_scripts/docs/changelog.md | 2 +- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/bfabric/docs/design/oauth_usage_and_troubleshooting.md b/bfabric/docs/design/oauth_usage_and_troubleshooting.md index c3c1a958..ead77ee6 100644 --- a/bfabric/docs/design/oauth_usage_and_troubleshooting.md +++ b/bfabric/docs/design/oauth_usage_and_troubleshooting.md @@ -76,9 +76,13 @@ like `containers`.** If a resource server authorizes by *your* container members token won't do — you need a user flow (PKCE or device code). The core `Bfabric` OAuth methods take `client_id` and `scope` as **required** arguments — there is -no baked-in default. The `bfabric-cli` login commands default the scope to -`"api:read api:write openid profile email groups"` — it **includes `groups`** (the employee -file-access path, see below) but not `containers` or `download`. +no baked-in default. The `bfabric-cli` login commands (`auth login` / `auth device-code`) default to +the **`read-write`** scope preset (`api:write`, which implies `api:read`); `read-only` (`api:read`) +and `upload` (`api:write tus`) presets are also available, and any scope can be passed with `--scope`. +These login presets are **minimal API scopes** — they do **not** include `groups` (the employee +file-access path, see below) or the OIDC scopes, so request those explicitly when you need them. +Client/webapp registration (`auth register` / `auth register-webapp`) keeps the broader +OIDC-inclusive default. --- @@ -94,9 +98,9 @@ File/download access is authorized by **container membership**, which a token ca > **Employees: request the `groups` scope — this is the practical PKCE path.** > The **`containers` scope cannot be requested via PKCE** (it is restricted; the server silently > drops it — which is exactly why requesting `containers` in an interactive flow yields a token -> *without* the claim and no error). **`groups`, however, is requestable and is in the CLI's -> default scope.** So an employee gets file access from a normal user flow as long as -> `groups` is in the requested scope: +> *without* the claim and no error). **`groups`, however, is requestable.** It is **not** part of +> the CLI's login presets (those are minimal API scopes), so pass it explicitly to get file access +> from a normal user flow: > > ```python > client = Bfabric.connect_pkce( diff --git a/bfabric/src/bfabric/transfer/tokens.py b/bfabric/src/bfabric/transfer/tokens.py index 0264c0eb..6b193d50 100644 --- a/bfabric/src/bfabric/transfer/tokens.py +++ b/bfabric/src/bfabric/transfer/tokens.py @@ -25,7 +25,8 @@ from bfabric import Bfabric from bfabric.config import BfabricAuth -# Scopes the CLI is pre-registered for but that DEFAULT_OAUTH_SCOPE does not request by default. +# Scopes the CLI is pre-registered for but that the default login presets do not request; the +# ``{scope}`` placeholder is filled with whatever extra scope the failed operation was missing. _PKCE_SCOPE_HINT = 'bfabric-cli auth login --scope "api:read api:write openid profile email groups {scope}"' diff --git a/bfabric_scripts/docs/changelog.md b/bfabric_scripts/docs/changelog.md index 1bcc74ca..3372be4a 100644 --- a/bfabric_scripts/docs/changelog.md +++ b/bfabric_scripts/docs/changelog.md @@ -15,7 +15,7 @@ Versioning currently follows `X.Y.Z` semantic versioning, independent of the `bf ## \[1.16.0rc2\] - 2026-07-15 - `bfabric-cli auth` command group for OAuth authentication and client management: - - Login: `auth login` (browser), `auth device-code` (headless), `auth pat` (Personal Access Token). When `--config-env` is omitted the command prompts for the environment (pick an existing one or type a new name, prefilled with the current default); non-interactively it targets the current default env, else `PRODUCTION`. `auth login` / `auth device-code` accept `--scope` as a named preset (`read-only` / `read-write` / `upload`, the last adding the `tus` upload scope) or a raw scope string, with an interactive picker (plus a Custom option) when omitted. Unless `--set-default` / `--no-set-default` is given, the command asks (default yes) whether the new environment should become the config default. + - Login: `auth login` (browser), `auth device-code` (headless), `auth pat` (Personal Access Token). When `--config-env` is omitted the command prompts for the environment (pick an existing one or type a new name, prefilled with the current default); non-interactively it targets the current default env, else `PRODUCTION`. `auth login` / `auth device-code` accept `--scope` as a named preset (`read-only` / `read-write` / `upload`, the last adding the `tus` upload scope) or a raw scope string, with an interactive picker (plus a Custom option) when omitted. The presets are minimal API scopes and do **not** include `groups` or the OIDC scopes — for employee file access, request `groups` explicitly (`--scope "api:write groups"` or the Custom option). Unless `--set-default` / `--no-set-default` is given, the command asks (default yes) whether the new environment should become the config default. - `auth register` / `auth register-webapp` — dynamic client registration, optionally with a linked B-Fabric app. - `auth default [CONFIG_ENV]` — set the default environment; an arrow-key interactive picker (navigate the list or type to filter, Enter to select; each row shows the host and auth method) opens when no value is given, or lists the environments in a non-interactive context. - `auth list` — list the configured environments (host + auth method), marking the default. From 7371d1565d089d449ae5ffa8abb073d8010ef02a Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Thu, 16 Jul 2026 09:58:08 +0200 Subject: [PATCH 15/29] feat(auth): CLI login scope presets + api:write default; rename registration scope Move the CLI OAuth scope policy into #562 (was reverted in b4175b54 as an interactive-picker follow-up; now homed here so #562 owns scope policy and #558 owns only the interactive picker). - cli/login/_constants.py: add use-case-oriented scope presets (read-only=api:read, read-write=api:write, upload=api:write tus) with a by-name index and read-write default; rename the broad registration-scope constant DEFAULT_OAUTH_SCOPE -> DEFAULT_REGISTRATION_SCOPE to say what it is now that login differs. - auth login / auth device-code default to the minimal read-write scope (api:write) instead of the broad OIDC set; registration keeps DEFAULT_REGISTRATION_SCOPE. - Simplify upload/re-auth scope hints (_PKCE_SCOPE_HINT, upload examples/docs) to 'api:write {scope}' since api:write implies api:read. --- .../design/oauth_usage_and_troubleshooting.md | 16 +++++--- .../docs/user_guides/bfabric-cli/workunits.md | 2 +- .../src/bfabric/examples/prove_tus_resume.py | 2 +- bfabric/src/bfabric/transfer/tokens.py | 6 ++- bfabric_scripts/docs/changelog.md | 2 + bfabric_scripts/example/test_webapp_flow.py | 6 +-- .../bfabric_scripts/cli/login/_constants.py | 39 +++++++++++++++++-- .../bfabric_scripts/cli/login/device_code.py | 4 +- .../src/bfabric_scripts/cli/login/pkce.py | 4 +- .../src/bfabric_scripts/cli/login/register.py | 4 +- .../cli/login/register_webapp.py | 4 +- .../bfabric_scripts/cli/workunit/upload.py | 2 +- .../cli/login/test_cmd_login_register.py | 6 +-- .../cli/login/test_login_constants.py | 30 ++++++++++++++ 14 files changed, 99 insertions(+), 28 deletions(-) create mode 100644 tests/bfabric_scripts/cli/login/test_login_constants.py diff --git a/bfabric/docs/design/oauth_usage_and_troubleshooting.md b/bfabric/docs/design/oauth_usage_and_troubleshooting.md index c3c1a958..ead77ee6 100644 --- a/bfabric/docs/design/oauth_usage_and_troubleshooting.md +++ b/bfabric/docs/design/oauth_usage_and_troubleshooting.md @@ -76,9 +76,13 @@ like `containers`.** If a resource server authorizes by *your* container members token won't do — you need a user flow (PKCE or device code). The core `Bfabric` OAuth methods take `client_id` and `scope` as **required** arguments — there is -no baked-in default. The `bfabric-cli` login commands default the scope to -`"api:read api:write openid profile email groups"` — it **includes `groups`** (the employee -file-access path, see below) but not `containers` or `download`. +no baked-in default. The `bfabric-cli` login commands (`auth login` / `auth device-code`) default to +the **`read-write`** scope preset (`api:write`, which implies `api:read`); `read-only` (`api:read`) +and `upload` (`api:write tus`) presets are also available, and any scope can be passed with `--scope`. +These login presets are **minimal API scopes** — they do **not** include `groups` (the employee +file-access path, see below) or the OIDC scopes, so request those explicitly when you need them. +Client/webapp registration (`auth register` / `auth register-webapp`) keeps the broader +OIDC-inclusive default. --- @@ -94,9 +98,9 @@ File/download access is authorized by **container membership**, which a token ca > **Employees: request the `groups` scope — this is the practical PKCE path.** > The **`containers` scope cannot be requested via PKCE** (it is restricted; the server silently > drops it — which is exactly why requesting `containers` in an interactive flow yields a token -> *without* the claim and no error). **`groups`, however, is requestable and is in the CLI's -> default scope.** So an employee gets file access from a normal user flow as long as -> `groups` is in the requested scope: +> *without* the claim and no error). **`groups`, however, is requestable.** It is **not** part of +> the CLI's login presets (those are minimal API scopes), so pass it explicitly to get file access +> from a normal user flow: > > ```python > client = Bfabric.connect_pkce( diff --git a/bfabric/docs/user_guides/bfabric-cli/workunits.md b/bfabric/docs/user_guides/bfabric-cli/workunits.md index b539cc5d..97e0ba80 100644 --- a/bfabric/docs/user_guides/bfabric-cli/workunits.md +++ b/bfabric/docs/user_guides/bfabric-cli/workunits.md @@ -147,7 +147,7 @@ carries the `tus` scope. Install the extra and authenticate once: ```bash pip install 'bfabric[transfer]' -bfabric-cli auth login --scope "api:read api:write openid profile email groups tus" +bfabric-cli auth login --scope "api:write tus" ``` ### Basic Usage diff --git a/bfabric/src/bfabric/examples/prove_tus_resume.py b/bfabric/src/bfabric/examples/prove_tus_resume.py index 66feba16..583b0e43 100644 --- a/bfabric/src/bfabric/examples/prove_tus_resume.py +++ b/bfabric/src/bfabric/examples/prove_tus_resume.py @@ -25,7 +25,7 @@ Prerequisites: an OAuth-backed config env with the ``tus`` scope. Authenticate once with:: - bfabric-cli auth login --scope "api:read api:write openid profile email groups tus" + bfabric-cli auth login --scope "api:write tus" then run like the equivalent CLI upload:: diff --git a/bfabric/src/bfabric/transfer/tokens.py b/bfabric/src/bfabric/transfer/tokens.py index 0264c0eb..c4f0bdc7 100644 --- a/bfabric/src/bfabric/transfer/tokens.py +++ b/bfabric/src/bfabric/transfer/tokens.py @@ -25,8 +25,10 @@ from bfabric import Bfabric from bfabric.config import BfabricAuth -# Scopes the CLI is pre-registered for but that DEFAULT_OAUTH_SCOPE does not request by default. -_PKCE_SCOPE_HINT = 'bfabric-cli auth login --scope "api:read api:write openid profile email groups {scope}"' +# Re-auth hint shown when a required transfer scope (``tus`` for upload, ``containers`` for download) +# is missing from the token: request read-write API access (``api:write`` implies ``api:read``) plus +# the missing scope. ``{scope}`` is filled with whatever the failed operation needed. +_PKCE_SCOPE_HINT = 'bfabric-cli auth login --scope "api:write {scope}"' def _safe_auth(client: Bfabric) -> BfabricAuth | None: diff --git a/bfabric_scripts/docs/changelog.md b/bfabric_scripts/docs/changelog.md index 4150e26a..91589eac 100644 --- a/bfabric_scripts/docs/changelog.md +++ b/bfabric_scripts/docs/changelog.md @@ -10,6 +10,8 @@ Versioning currently follows `X.Y.Z` semantic versioning, independent of the `bf ## \[Unreleased\] +- `auth login` / `auth device-code` now request a minimal, use-case-oriented scope by default: read-write (`api:write`, which implies `api:read`) instead of the previous broad `api:read api:write openid profile email groups`. Named presets are available — `read-only` (`api:read`), `read-write` (`api:write`), `upload` (`api:write tus`) — and any scope can still be passed explicitly via `--scope`. Client/webapp registration (`auth register` / `auth register-webapp`) keeps the OIDC-inclusive default, which webapps need for user-identity claims. + ## \[1.16.0rc2\] - 2026-07-15 - `bfabric-cli auth` command group for OAuth authentication and client management: diff --git a/bfabric_scripts/example/test_webapp_flow.py b/bfabric_scripts/example/test_webapp_flow.py index d4f5f008..0465acc6 100644 --- a/bfabric_scripts/example/test_webapp_flow.py +++ b/bfabric_scripts/example/test_webapp_flow.py @@ -26,7 +26,7 @@ from bfabric._oauth.registration import register_webapp from bfabric._oauth.webapp_client import WebappClient from bfabric.entities.core.uri import EntityUri -from bfabric_scripts.cli.login._constants import DEFAULT_OAUTH_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_REGISTRATION_SCOPE PORT = 19876 REDIRECT_PATH = "/callback" @@ -57,7 +57,7 @@ def main() -> None: app_name=app_name, web_url=web_url, service_user="itfeeder", - scope=DEFAULT_OAUTH_SCOPE, + scope=DEFAULT_REGISTRATION_SCOPE, hidden=True, ) oauth_info = result["oauth"] @@ -151,7 +151,7 @@ def serve() -> None: launch_token=jwt, client_id=oauth_client_id, client_secret=oauth_client_secret, - scope=DEFAULT_OAUTH_SCOPE, + scope=DEFAULT_REGISTRATION_SCOPE, ) except Exception as e: print(f"\nERROR: Token exchange failed: {e}", file=sys.stderr) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py index 540d6a6d..0bb9e6c4 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py @@ -1,10 +1,43 @@ -"""Default OAuth client ID and scope for the CLI login commands. +"""Default OAuth client ID and scope policy for the CLI login commands. These are CLI-level policy: ``"CLI"`` is the client identity the CLI registers -as, and the scope string is the set of permissions the CLI requests. The core +as, and the scopes below are the permission sets the CLI requests. The core ``bfabric`` library deliberately does not bake in these defaults — its OAuth API requires callers to state ``client_id`` and ``scope`` explicitly. """ +from __future__ import annotations + +from typing import NamedTuple + DEFAULT_CLIENT_ID = "CLI" -DEFAULT_OAUTH_SCOPE = "api:read api:write openid profile email groups" + +# Scope requested when *registering* an OAuth client/webapp (``auth register`` / +# ``auth register-webapp``). Broad and OIDC-inclusive on purpose: a registered webapp +# needs ``openid profile email groups`` for the user-identity claims it reads from the +# URL-token exchange, which the login presets below intentionally omit. +DEFAULT_REGISTRATION_SCOPE = "api:read api:write openid profile email groups" + + +class ScopePreset(NamedTuple): + """A named, use-case-oriented OAuth scope set offered at interactive login.""" + + name: str + scope: str + description: str + + +# Scope presets for interactive *login* (``auth login`` / ``auth device-code``), ordered +# by increasing capability. ``api:write`` implies ``api:read`` server-side, so the +# read-write set lists only ``api:write``; ``tus`` adds file-upload permission on top. +SCOPE_PRESETS: tuple[ScopePreset, ...] = ( + ScopePreset("read-only", "api:read", "Read from the API"), + ScopePreset("read-write", "api:write", "Write to the API (includes read from the API)"), + ScopePreset("upload", "api:write tus", "Upload files to the API (includes read and write API)"), +) + +SCOPE_PRESETS_BY_NAME: dict[str, ScopePreset] = {preset.name: preset for preset in SCOPE_PRESETS} + +# The preset requested by default when the user does not pick one. +DEFAULT_SCOPE_PRESET = "read-write" +DEFAULT_LOGIN_SCOPE = SCOPE_PRESETS_BY_NAME[DEFAULT_SCOPE_PRESET].scope diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py index c7478feb..c7eb57b4 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py @@ -12,7 +12,7 @@ from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_LOGIN_SCOPE def cmd_login_device_code( @@ -21,7 +21,7 @@ def cmd_login_device_code( client_id: Annotated[str, cyclopts.Parameter(help="OAuth client ID.")] = DEFAULT_CLIENT_ID, config_env: Annotated[str, cyclopts.Parameter(help="Environment name in the config file.")] = "PRODUCTION", config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_OAUTH_SCOPE, + scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_LOGIN_SCOPE, timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for authorization.")] = 600.0, set_default: Annotated[ bool, cyclopts.Parameter(help="Set this environment as the default in the config file.") diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py index 7601313e..0678c3d8 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py @@ -12,7 +12,7 @@ from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_LOGIN_SCOPE def cmd_auth_login( @@ -21,7 +21,7 @@ def cmd_auth_login( client_id: Annotated[str, cyclopts.Parameter(help="OAuth client ID.")] = DEFAULT_CLIENT_ID, config_env: Annotated[str, cyclopts.Parameter(help="Environment name in the config file.")] = "PRODUCTION", config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_OAUTH_SCOPE, + scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_LOGIN_SCOPE, port: Annotated[int, cyclopts.Parameter(help="Local port for callback (0 = auto).")] = 0, timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for login.")] = 120.0, set_default: Annotated[ diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/register.py b/bfabric_scripts/src/bfabric_scripts/cli/login/register.py index 2c28728f..e332d672 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/register.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/register.py @@ -12,7 +12,7 @@ from bfabric._oauth.registration import register_client from bfabric.config import DEFAULT_CONFIG_FILE -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_REGISTRATION_SCOPE def _resolve_token_from_config(config_env: str, config_file: Path) -> tuple[str, str]: @@ -79,7 +79,7 @@ def cmd_login_register( service_user: Annotated[ str | None, cyclopts.Parameter(help="Service user login (enables client_credentials grant).") ] = None, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_OAUTH_SCOPE, + scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_REGISTRATION_SCOPE, grant_types: Annotated[ list[str] | None, cyclopts.Parameter(help="Grant types to request (overrides default webapp grants)."), diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py b/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py index 78cf599b..0d623764 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py @@ -10,7 +10,7 @@ import cyclopts from bfabric.config import DEFAULT_CONFIG_FILE -from bfabric_scripts.cli.login._constants import DEFAULT_OAUTH_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_REGISTRATION_SCOPE def cmd_login_register_webapp( @@ -22,7 +22,7 @@ def cmd_login_register_webapp( service_user: Annotated[ str | None, cyclopts.Parameter(help="Service user login (enables client_credentials grant).") ] = None, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_OAUTH_SCOPE, + scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_REGISTRATION_SCOPE, application_id: Annotated[ int | None, cyclopts.Parameter(help="Existing application ID to update (omit to create new).") ] = None, diff --git a/bfabric_scripts/src/bfabric_scripts/cli/workunit/upload.py b/bfabric_scripts/src/bfabric_scripts/cli/workunit/upload.py index 01132af2..31b632dd 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/workunit/upload.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/workunit/upload.py @@ -75,7 +75,7 @@ def cmd_workunit_upload(params: UploadParams, *, client: Bfabric) -> None: Creates a new workunit, or uploads into an existing one with ``--workunit-id``. Requires an OAuth-backed client with the ``tus`` scope; authenticate with - ``bfabric-cli auth login --scope "api:read api:write openid profile email groups tus"``. + ``bfabric-cli auth login --scope "api:write tus"``. """ with _upload_progress(enabled=_progress_enabled(requested=params.progress)) as reporter: summary = upload_files( diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_register.py b/tests/bfabric_scripts/cli/login/test_cmd_login_register.py index e4fdb8f1..a170230d 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_register.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_register.py @@ -5,7 +5,7 @@ import pytest import yaml -from bfabric_scripts.cli.login._constants import DEFAULT_OAUTH_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_REGISTRATION_SCOPE from bfabric_scripts.cli.login.register import cmd_login_register @@ -107,7 +107,7 @@ def test_uses_cached_token_from_config_env(self, tmp_path, mocker, capsys): client_name="My App", redirect_uri="http://localhost/callback", service_user=None, - scope=DEFAULT_OAUTH_SCOPE, + scope=DEFAULT_REGISTRATION_SCOPE, grant_types=None, ) output = capsys.readouterr() @@ -154,7 +154,7 @@ def test_explicit_base_url_overrides_config(self, tmp_path, mocker, capsys): client_name="My App", redirect_uri="http://localhost/callback", service_user=None, - scope=DEFAULT_OAUTH_SCOPE, + scope=DEFAULT_REGISTRATION_SCOPE, grant_types=None, ) diff --git a/tests/bfabric_scripts/cli/login/test_login_constants.py b/tests/bfabric_scripts/cli/login/test_login_constants.py new file mode 100644 index 00000000..45c061ae --- /dev/null +++ b/tests/bfabric_scripts/cli/login/test_login_constants.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from bfabric_scripts.cli.login._constants import ( + DEFAULT_LOGIN_SCOPE, + DEFAULT_SCOPE_PRESET, + SCOPE_PRESETS, + SCOPE_PRESETS_BY_NAME, +) + + +class TestScopePresets: + def test_presets_are_minimal_use_case_sets(self): + # Login presets deliberately omit the OIDC/groups scopes (those live in + # DEFAULT_REGISTRATION_SCOPE for registration), and api:write implies api:read + # server-side so read-write lists only api:write. + assert [(p.name, p.scope) for p in SCOPE_PRESETS] == [ + ("read-only", "api:read"), + ("read-write", "api:write"), + ("upload", "api:write tus"), + ] + + def test_every_preset_has_a_description(self): + assert all(preset.description for preset in SCOPE_PRESETS) + + def test_by_name_index_covers_all_presets(self): + assert set(SCOPE_PRESETS_BY_NAME) == {preset.name for preset in SCOPE_PRESETS} + + def test_default_login_scope_is_read_write(self): + assert DEFAULT_SCOPE_PRESET == "read-write" + assert DEFAULT_LOGIN_SCOPE == SCOPE_PRESETS_BY_NAME["read-write"].scope == "api:write" From 8c089b167715cdf341fbed9edad7cd42d4feb777 Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Thu, 16 Jul 2026 10:15:36 +0200 Subject: [PATCH 16/29] refactor(auth): represent CLI login scope presets as a name->scope map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the ScopePreset NamedTuple and SCOPE_PRESETS_BY_NAME index in favour of a plain {name: scope} dict — shorter, and it removes the three same-typed positional args that made preset construction easy to get wrong. Human-readable descriptions are UI copy and move to the interactive picker (bfabric_scripts _common). --- .../bfabric_scripts/cli/login/_constants.py | 46 ++++++------------- .../cli/login/test_login_constants.py | 19 +++----- 2 files changed, 19 insertions(+), 46 deletions(-) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py index 0bb9e6c4..52a92680 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py @@ -1,43 +1,23 @@ -"""Default OAuth client ID and scope policy for the CLI login commands. +"""OAuth client ID and scope policy for the CLI ``auth`` commands. -These are CLI-level policy: ``"CLI"`` is the client identity the CLI registers -as, and the scopes below are the permission sets the CLI requests. The core -``bfabric`` library deliberately does not bake in these defaults — its OAuth -API requires callers to state ``client_id`` and ``scope`` explicitly. +CLI-level policy — the core ``bfabric`` library bakes in no defaults and requires +callers to pass ``client_id``/``scope`` explicitly. """ from __future__ import annotations -from typing import NamedTuple - DEFAULT_CLIENT_ID = "CLI" -# Scope requested when *registering* an OAuth client/webapp (``auth register`` / -# ``auth register-webapp``). Broad and OIDC-inclusive on purpose: a registered webapp -# needs ``openid profile email groups`` for the user-identity claims it reads from the -# URL-token exchange, which the login presets below intentionally omit. +# Broad OIDC-inclusive scope for client/webapp *registration*: webapps need the +# ``openid profile email groups`` identity claims that the login presets omit. DEFAULT_REGISTRATION_SCOPE = "api:read api:write openid profile email groups" - -class ScopePreset(NamedTuple): - """A named, use-case-oriented OAuth scope set offered at interactive login.""" - - name: str - scope: str - description: str - - -# Scope presets for interactive *login* (``auth login`` / ``auth device-code``), ordered -# by increasing capability. ``api:write`` implies ``api:read`` server-side, so the -# read-write set lists only ``api:write``; ``tus`` adds file-upload permission on top. -SCOPE_PRESETS: tuple[ScopePreset, ...] = ( - ScopePreset("read-only", "api:read", "Read from the API"), - ScopePreset("read-write", "api:write", "Write to the API (includes read from the API)"), - ScopePreset("upload", "api:write tus", "Upload files to the API (includes read and write API)"), -) - -SCOPE_PRESETS_BY_NAME: dict[str, ScopePreset] = {preset.name: preset for preset in SCOPE_PRESETS} - -# The preset requested by default when the user does not pick one. +# Login scope presets (name -> scope), by increasing capability. ``api:write`` implies +# ``api:read``; ``tus`` adds upload. Human-readable labels live in the picker (``_common``). +SCOPE_PRESETS: dict[str, str] = { + "read-only": "api:read", + "read-write": "api:write", + "upload": "api:write tus", +} DEFAULT_SCOPE_PRESET = "read-write" -DEFAULT_LOGIN_SCOPE = SCOPE_PRESETS_BY_NAME[DEFAULT_SCOPE_PRESET].scope +DEFAULT_LOGIN_SCOPE = SCOPE_PRESETS[DEFAULT_SCOPE_PRESET] diff --git a/tests/bfabric_scripts/cli/login/test_login_constants.py b/tests/bfabric_scripts/cli/login/test_login_constants.py index 45c061ae..3e84d28b 100644 --- a/tests/bfabric_scripts/cli/login/test_login_constants.py +++ b/tests/bfabric_scripts/cli/login/test_login_constants.py @@ -4,7 +4,6 @@ DEFAULT_LOGIN_SCOPE, DEFAULT_SCOPE_PRESET, SCOPE_PRESETS, - SCOPE_PRESETS_BY_NAME, ) @@ -13,18 +12,12 @@ def test_presets_are_minimal_use_case_sets(self): # Login presets deliberately omit the OIDC/groups scopes (those live in # DEFAULT_REGISTRATION_SCOPE for registration), and api:write implies api:read # server-side so read-write lists only api:write. - assert [(p.name, p.scope) for p in SCOPE_PRESETS] == [ - ("read-only", "api:read"), - ("read-write", "api:write"), - ("upload", "api:write tus"), - ] - - def test_every_preset_has_a_description(self): - assert all(preset.description for preset in SCOPE_PRESETS) - - def test_by_name_index_covers_all_presets(self): - assert set(SCOPE_PRESETS_BY_NAME) == {preset.name for preset in SCOPE_PRESETS} + assert SCOPE_PRESETS == { + "read-only": "api:read", + "read-write": "api:write", + "upload": "api:write tus", + } def test_default_login_scope_is_read_write(self): assert DEFAULT_SCOPE_PRESET == "read-write" - assert DEFAULT_LOGIN_SCOPE == SCOPE_PRESETS_BY_NAME["read-write"].scope == "api:write" + assert DEFAULT_LOGIN_SCOPE == SCOPE_PRESETS["read-write"] == "api:write" From 12f08aeb9612caf6114ab79c67de4761c410dfc3 Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Thu, 16 Jul 2026 10:15:37 +0200 Subject: [PATCH 17/29] refactor(oauth): put required keyword-only args before defaulted ones In register_client / register_webapp the required 'scope' sat after the defaulted 'service_user'. Legal for keyword-only args, but list the required one first for consistency; docstrings reordered to match. No call-site impact (keyword-only). --- bfabric/src/bfabric/_oauth/registration.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bfabric/src/bfabric/_oauth/registration.py b/bfabric/src/bfabric/_oauth/registration.py index 5d92c5ee..c44412ce 100644 --- a/bfabric/src/bfabric/_oauth/registration.py +++ b/bfabric/src/bfabric/_oauth/registration.py @@ -37,8 +37,8 @@ def register_client( client_name: str, redirect_uri: str, *, - service_user: str | None = None, scope: str, + service_user: str | None = None, grant_types: list[str] | None = None, ) -> dict[str, object]: """Register a new OAuth client with the B-Fabric server. @@ -53,8 +53,8 @@ def register_client( :param token: Employee Bearer token for authorization :param client_name: Human-readable name for the client :param redirect_uri: OAuth redirect URI for the client - :param service_user: Optional service user login to enable ``client_credentials`` grant :param scope: OAuth scope string + :param service_user: Optional service user login to enable ``client_credentials`` grant :param grant_types: Explicit list of grant types to request (overrides the default) :returns: Registration response containing ``client_id``, ``client_secret``, etc. """ @@ -88,8 +88,8 @@ def register_webapp( app_name: str, web_url: str, *, - service_user: str | None = None, scope: str, + service_user: str | None = None, application_id: int | None = None, technology_id: int | None = None, description: str | None = None, @@ -106,8 +106,8 @@ def register_webapp( :param token: Employee Bearer token for the OAuth registration endpoint :param app_name: Human-readable name for both the OAuth client and the application :param web_url: The webapp URL (used as both OAuth redirect URI and application ``weburl``) - :param service_user: Optional service user login to enable ``client_credentials`` grant :param scope: OAuth scope string + :param service_user: Optional service user login to enable ``client_credentials`` grant :param application_id: Existing application ID to update (omit to create a new application) :param technology_id: Technology ID for the application :param description: Application description From 1c7ef0452234a3c3ae9deb5e62718f1a04064456 Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Thu, 16 Jul 2026 10:31:04 +0200 Subject: [PATCH 18/29] refactor(auth): keep ScopePreset NamedTuple for login scope presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the name->scope dict switch (8c089b16). The NamedTuple keeps each preset's human-readable description co-located with its name and scope — which the interactive picker (bfabric_scripts _common) consumes — instead of splitting descriptions into a parallel map. Restores ScopePreset, SCOPE_PRESETS, and SCOPE_PRESETS_BY_NAME. --- .../bfabric_scripts/cli/login/_constants.py | 46 +++++++++++++------ .../cli/login/test_login_constants.py | 19 +++++--- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py index 52a92680..0bb9e6c4 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py @@ -1,23 +1,43 @@ -"""OAuth client ID and scope policy for the CLI ``auth`` commands. +"""Default OAuth client ID and scope policy for the CLI login commands. -CLI-level policy — the core ``bfabric`` library bakes in no defaults and requires -callers to pass ``client_id``/``scope`` explicitly. +These are CLI-level policy: ``"CLI"`` is the client identity the CLI registers +as, and the scopes below are the permission sets the CLI requests. The core +``bfabric`` library deliberately does not bake in these defaults — its OAuth +API requires callers to state ``client_id`` and ``scope`` explicitly. """ from __future__ import annotations +from typing import NamedTuple + DEFAULT_CLIENT_ID = "CLI" -# Broad OIDC-inclusive scope for client/webapp *registration*: webapps need the -# ``openid profile email groups`` identity claims that the login presets omit. +# Scope requested when *registering* an OAuth client/webapp (``auth register`` / +# ``auth register-webapp``). Broad and OIDC-inclusive on purpose: a registered webapp +# needs ``openid profile email groups`` for the user-identity claims it reads from the +# URL-token exchange, which the login presets below intentionally omit. DEFAULT_REGISTRATION_SCOPE = "api:read api:write openid profile email groups" -# Login scope presets (name -> scope), by increasing capability. ``api:write`` implies -# ``api:read``; ``tus`` adds upload. Human-readable labels live in the picker (``_common``). -SCOPE_PRESETS: dict[str, str] = { - "read-only": "api:read", - "read-write": "api:write", - "upload": "api:write tus", -} + +class ScopePreset(NamedTuple): + """A named, use-case-oriented OAuth scope set offered at interactive login.""" + + name: str + scope: str + description: str + + +# Scope presets for interactive *login* (``auth login`` / ``auth device-code``), ordered +# by increasing capability. ``api:write`` implies ``api:read`` server-side, so the +# read-write set lists only ``api:write``; ``tus`` adds file-upload permission on top. +SCOPE_PRESETS: tuple[ScopePreset, ...] = ( + ScopePreset("read-only", "api:read", "Read from the API"), + ScopePreset("read-write", "api:write", "Write to the API (includes read from the API)"), + ScopePreset("upload", "api:write tus", "Upload files to the API (includes read and write API)"), +) + +SCOPE_PRESETS_BY_NAME: dict[str, ScopePreset] = {preset.name: preset for preset in SCOPE_PRESETS} + +# The preset requested by default when the user does not pick one. DEFAULT_SCOPE_PRESET = "read-write" -DEFAULT_LOGIN_SCOPE = SCOPE_PRESETS[DEFAULT_SCOPE_PRESET] +DEFAULT_LOGIN_SCOPE = SCOPE_PRESETS_BY_NAME[DEFAULT_SCOPE_PRESET].scope diff --git a/tests/bfabric_scripts/cli/login/test_login_constants.py b/tests/bfabric_scripts/cli/login/test_login_constants.py index 3e84d28b..45c061ae 100644 --- a/tests/bfabric_scripts/cli/login/test_login_constants.py +++ b/tests/bfabric_scripts/cli/login/test_login_constants.py @@ -4,6 +4,7 @@ DEFAULT_LOGIN_SCOPE, DEFAULT_SCOPE_PRESET, SCOPE_PRESETS, + SCOPE_PRESETS_BY_NAME, ) @@ -12,12 +13,18 @@ def test_presets_are_minimal_use_case_sets(self): # Login presets deliberately omit the OIDC/groups scopes (those live in # DEFAULT_REGISTRATION_SCOPE for registration), and api:write implies api:read # server-side so read-write lists only api:write. - assert SCOPE_PRESETS == { - "read-only": "api:read", - "read-write": "api:write", - "upload": "api:write tus", - } + assert [(p.name, p.scope) for p in SCOPE_PRESETS] == [ + ("read-only", "api:read"), + ("read-write", "api:write"), + ("upload", "api:write tus"), + ] + + def test_every_preset_has_a_description(self): + assert all(preset.description for preset in SCOPE_PRESETS) + + def test_by_name_index_covers_all_presets(self): + assert set(SCOPE_PRESETS_BY_NAME) == {preset.name for preset in SCOPE_PRESETS} def test_default_login_scope_is_read_write(self): assert DEFAULT_SCOPE_PRESET == "read-write" - assert DEFAULT_LOGIN_SCOPE == SCOPE_PRESETS["read-write"] == "api:write" + assert DEFAULT_LOGIN_SCOPE == SCOPE_PRESETS_BY_NAME["read-write"].scope == "api:write" From 127a9532a15f27943a8864b64b34c56fd9f84f6d Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Thu, 16 Jul 2026 13:58:24 +0200 Subject: [PATCH 19/29] refactor(bfabric_scripts): drop over-abstractions in auth CLI helpers No behaviour change; removes indirection that wasn't earning its keep. - Delete cli.interactive.resolve_choice: it was used fully by only one caller (auth default) and half-bypassed by resolve_config_env, which pre-checks the explicit value and the TTY itself and then called it with value=None (two of its four branches dead on that path). Both callers now use select_choice / select_or_input directly. Drops its 4 unit tests; behaviour stays covered by the auth-default and login-common suites. - Inline three single-use _common helpers into their only callers: _existing_environments -> resolve_config_env, auth_method_label -> environment_summary (also dropping a stale "mirrors auth status" note, since status keeps its own copy), environment_line -> print_environments. Net -38 production lines / -64 with tests. Tests + basedpyright green. --- .../src/bfabric_scripts/cli/interactive.py | 34 +-------- .../src/bfabric_scripts/cli/login/_common.py | 72 ++++++++----------- .../cli/login/default_config.py | 18 ++--- .../cli/login/test_cmd_auth_default.py | 6 +- .../cli/login/test_login_common.py | 16 ++--- tests/bfabric_scripts/cli/test_interactive.py | 26 +------ 6 files changed, 54 insertions(+), 118 deletions(-) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py index 118d75a8..6ae3488d 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py @@ -1,9 +1,8 @@ """Reusable interactive prompt helpers built on ``questionary``. -These wrap the recurring CLI pattern where a value may be supplied on the command line, -selected from a menu, or (optionally) typed as a new value not in the list. Interactive -prompts require a TTY; call sites in non-interactive contexts (pipes, CI) either pass an -explicit value or handle the ``None`` returned by :func:`resolve_choice`. +These wrap the recurring CLI pattern where a value is selected from a menu or (optionally) +typed as a new value not in the list. Interactive prompts require a TTY; callers guard with +:func:`is_interactive` and handle the ``None`` a cancelled prompt returns. """ from __future__ import annotations @@ -83,30 +82,3 @@ def confirm(message: str, *, default: bool = False) -> bool | None: "no" (a caller that wants to treat both alike can just check falsiness). """ return cast("bool | None", questionary.confirm(message, default=default).ask()) - - -def resolve_choice( - value: str | None, - choices: Sequence[str], - *, - message: str, - allow_new: bool = False, - default: str | None = None, - describe: Callable[[str], str] | None = None, - search: bool = False, -) -> str | None: - """Resolve a choice from an explicit *value* or, failing that, an interactive prompt. - - * *value* given -> return it verbatim (the caller validates membership if it needs to). - * else, no TTY -> return ``None`` (the caller reports the non-interactive case). - * else, *allow_new* -> :func:`select_or_input` (pick a suggestion or type a new value). - * else -> :func:`select_choice` (menu; *describe* labels each entry, *search* enables - type-to-filter). - """ - if value is not None: - return value - if not is_interactive(): - return None - if allow_new: - return select_or_input(message, choices, default=default) - return select_choice(message, choices, default=default, describe=describe, search=search) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py index 92ff677e..bfa19389 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py @@ -16,7 +16,7 @@ from rich.text import Text from bfabric.config.config_file import ConfigFile, EnvironmentConfig -from bfabric_scripts.cli.interactive import confirm, is_interactive, resolve_choice, select_choice, text_input +from bfabric_scripts.cli.interactive import confirm, is_interactive, select_choice, select_or_input, text_input from bfabric_scripts.cli.login._constants import ( DEFAULT_LOGIN_SCOPE, DEFAULT_SCOPE_PRESET, @@ -31,15 +31,6 @@ _FALLBACK_ENV = "PRODUCTION" -def _existing_environments(config_file: Path) -> tuple[list[str], str | None]: - """Return the configured environment names and the current default (``[]``, ``None`` if absent).""" - config_path = Path(config_file).expanduser() - if not config_path.is_file(): - return [], None - config_file_obj = ConfigFile.model_validate(yaml.safe_load(config_path.read_text())) - return list(config_file_obj.environments), config_file_obj.general.default_config - - def resolve_config_env(config_env: str | None, config_file: Path) -> str | None: """Resolve the target environment name. @@ -50,11 +41,16 @@ def resolve_config_env(config_env: str | None, config_file: Path) -> str | None: """ if config_env is not None: return config_env - names, current = _existing_environments(config_file) + config_path = Path(config_file).expanduser() + if config_path.is_file(): + loaded = ConfigFile.model_validate(yaml.safe_load(config_path.read_text())) + names, current = list(loaded.environments), loaded.general.default_config + else: + names, current = [], None fallback = current or _FALLBACK_ENV if not is_interactive(): return fallback - return resolve_choice(None, names, message="Environment name", allow_new=True, default=fallback) + return select_or_input("Environment name", names, default=fallback) def _scope_menu_label(choice: str) -> str: @@ -156,43 +152,37 @@ def describe_token_cache(cached: dict[str, object] | None, *, now: float) -> str return f"present, expires in {_format_duration(remaining)}" -def auth_method_label(env: EnvironmentConfig) -> str: - """Label an environment's auth method, mirroring ``auth status``' precedence.""" - if env.auth_method in ("oauth", "pat"): - return env.auth_method - if env.auth is not None: - return "password" - return "none" - - def environment_summary(env: EnvironmentConfig) -> str: """A compact "host · auth-method" descriptor shown next to each environment name.""" + if env.auth_method in ("oauth", "pat"): + method = env.auth_method + elif env.auth is not None: + method = "password" + else: + method = "none" base_url = str(env.config.base_url) host = urlsplit(base_url).netloc or base_url - return f"{host} · {auth_method_label(env)}" + return f"{host} · {method}" -def environment_line(name: str, *, summary: str, width: int, is_default: bool) -> Text: - """Render one environment row. Text (not markup) keeps names with "[" literal. +def print_environments(console: Console, environments: dict[str, EnvironmentConfig], default: str | None) -> None: + """List the configured environments with their host/auth summary, marking the default. - The current default is prefixed with a bold-green arrow so it stands out in a long list; - the "host · auth-method" summary trails the (padded) name. + Rows are built as ``Text`` (not console markup) so a name containing "[" stays literal; the + current default gets a bold-green arrow so it stands out in a long list. """ - padded = name.ljust(width) - if is_default: - # Chained appends (each returns the Text) keep the whole row in one expression. - return ( - Text("→ ", style="bold green") - .append(padded, style="bold green") - .append(f" {summary}", style="green") - .append(" (default)", style="green") - ) - return Text(" ").append(padded).append(f" {summary}", style="dim") - - -def print_environments(console: Console, environments: dict[str, EnvironmentConfig], default: str | None) -> None: - """List the configured environments with their host/auth summary, marking the default.""" console.print("Configuration environments:") width = max((len(name) for name in environments), default=0) for name, env in environments.items(): - console.print(environment_line(name, summary=environment_summary(env), width=width, is_default=name == default)) + padded = name.ljust(width) + summary = environment_summary(env) + if name == default: + row = ( + Text("→ ", style="bold green") + .append(padded, style="bold green") + .append(f" {summary}", style="green") + .append(" (default)", style="green") + ) + else: + row = Text(" ").append(padded).append(f" {summary}", style="dim") + console.print(row) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py b/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py index 0bf60059..f5beb85e 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py @@ -12,7 +12,7 @@ from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_file import ConfigFile from bfabric.config.config_writer import set_default_config -from bfabric_scripts.cli.interactive import is_interactive, resolve_choice +from bfabric_scripts.cli.interactive import is_interactive, select_choice from bfabric_scripts.cli.login._common import environment_summary, print_environments @@ -42,14 +42,14 @@ def cmd_auth_default( default = config_file_obj.general.default_config width = max(len(name) for name in names) - config_env = resolve_choice( - config_env, - names, - message="Select the default environment", - default=default if default in names else None, - describe=lambda name: f"{name.ljust(width)} {environment_summary(config_file_obj.environments[name])}", - search=True, - ) + if config_env is None and is_interactive(): + config_env = select_choice( + "Select the default environment", + names, + default=default if default in names else None, + describe=lambda name: f"{name.ljust(width)} {environment_summary(config_file_obj.environments[name])}", + search=True, + ) if config_env is None: # None means either the user cancelled the picker or there is no TTY to prompt on. console = Console() diff --git a/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py b/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py index 6732195b..eb0b713d 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py @@ -46,7 +46,8 @@ def test_selects_default_via_picker(self, tmp_path, mocker): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") # The picker runs only when no value is passed; it returns the chosen environment. - picker = mocker.patch("bfabric_scripts.cli.login.default_config.resolve_choice", return_value="TEST") + mocker.patch("bfabric_scripts.cli.login.default_config.is_interactive", return_value=True) + picker = mocker.patch("bfabric_scripts.cli.login.default_config.select_choice", return_value="TEST") cmd_auth_default(config_file=config_file) data = yaml.safe_load(config_file.read_text()) assert data["GENERAL"]["default_config"] == "TEST" @@ -57,7 +58,7 @@ def test_cancelled_picker_leaves_file_unchanged(self, tmp_path, mocker, capsys): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") before = config_file.read_text() - mocker.patch("bfabric_scripts.cli.login.default_config.resolve_choice", return_value=None) + mocker.patch("bfabric_scripts.cli.login.default_config.select_choice", return_value=None) mocker.patch("bfabric_scripts.cli.login.default_config.is_interactive", return_value=True) cmd_auth_default(config_file=config_file) assert "No changes made" in capsys.readouterr().out @@ -69,7 +70,6 @@ def test_lists_environments_when_no_tty_and_no_value(self, tmp_path, mocker, cap config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") before = config_file.read_text() - mocker.patch("bfabric_scripts.cli.login.default_config.resolve_choice", return_value=None) mocker.patch("bfabric_scripts.cli.login.default_config.is_interactive", return_value=False) cmd_auth_default(config_file=config_file) output = capsys.readouterr().out diff --git a/tests/bfabric_scripts/cli/login/test_login_common.py b/tests/bfabric_scripts/cli/login/test_login_common.py index 9cda7aba..fb4e5f53 100644 --- a/tests/bfabric_scripts/cli/login/test_login_common.py +++ b/tests/bfabric_scripts/cli/login/test_login_common.py @@ -28,9 +28,9 @@ def _write_config(config_file, default="PROD"): class TestResolveConfigEnv: def test_explicit_value_returned_as_is(self, tmp_path, mocker): # An explicit value short-circuits: no file read, no prompt. - resolve = mocker.patch("bfabric_scripts.cli.login._common.resolve_choice") + prompt = mocker.patch("bfabric_scripts.cli.login._common.select_or_input") assert resolve_config_env("STAGE", tmp_path / "missing.yml") == "STAGE" - resolve.assert_not_called() + prompt.assert_not_called() def test_non_interactive_uses_current_default(self, tmp_path, mocker): config_file = tmp_path / "config.yml" @@ -52,21 +52,19 @@ def test_interactive_offers_existing_and_allows_new(self, tmp_path, mocker): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) - resolve = mocker.patch("bfabric_scripts.cli.login._common.resolve_choice", return_value="NEWENV") + prompt = mocker.patch("bfabric_scripts.cli.login._common.select_or_input", return_value="NEWENV") assert resolve_config_env(None, config_file) == "NEWENV" - args = resolve.call_args.args - kwargs = resolve.call_args.kwargs - assert args[0] is None + args = prompt.call_args.args + # select_or_input offers the existing names as suggestions but lets the user type a new one. assert set(args[1]) == {"PROD", "TEST"} - assert kwargs["allow_new"] is True # The current default is prefilled. - assert kwargs["default"] == "PROD" + assert prompt.call_args.kwargs["default"] == "PROD" def test_interactive_cancel_returns_none(self, tmp_path, mocker): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) - mocker.patch("bfabric_scripts.cli.login._common.resolve_choice", return_value=None) + mocker.patch("bfabric_scripts.cli.login._common.select_or_input", return_value=None) assert resolve_config_env(None, config_file) is None diff --git a/tests/bfabric_scripts/cli/test_interactive.py b/tests/bfabric_scripts/cli/test_interactive.py index fa5555e3..98198a4b 100644 --- a/tests/bfabric_scripts/cli/test_interactive.py +++ b/tests/bfabric_scripts/cli/test_interactive.py @@ -2,31 +2,7 @@ import questionary -from bfabric_scripts.cli.interactive import confirm, resolve_choice, select_choice, select_or_input, text_input - - -class TestResolveChoice: - def test_returns_explicit_value_verbatim(self, mocker): - # An explicit value short-circuits: no interactivity check, no prompt. - select = mocker.patch("bfabric_scripts.cli.interactive.select_choice") - assert resolve_choice("TEST", ["PROD", "TEST"], message="Pick") == "TEST" - select.assert_not_called() - - def test_returns_none_when_not_interactive(self, mocker): - mocker.patch("bfabric_scripts.cli.interactive.is_interactive", return_value=False) - assert resolve_choice(None, ["PROD", "TEST"], message="Pick") is None - - def test_interactive_uses_select_choice(self, mocker): - mocker.patch("bfabric_scripts.cli.interactive.is_interactive", return_value=True) - select = mocker.patch("bfabric_scripts.cli.interactive.select_choice", return_value="PROD") - assert resolve_choice(None, ["PROD", "TEST"], message="Pick", default="PROD") == "PROD" - select.assert_called_once_with("Pick", ["PROD", "TEST"], default="PROD", describe=None, search=False) - - def test_allow_new_uses_select_or_input(self, mocker): - mocker.patch("bfabric_scripts.cli.interactive.is_interactive", return_value=True) - prompt = mocker.patch("bfabric_scripts.cli.interactive.select_or_input", return_value="NEW") - assert resolve_choice(None, ["PROD"], message="Pick", allow_new=True) == "NEW" - prompt.assert_called_once_with("Pick", ["PROD"], default=None) +from bfabric_scripts.cli.interactive import confirm, select_choice, select_or_input, text_input class TestSelectChoice: From fc1b59ff87d93edee562d481992dcc493c01861c Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Thu, 16 Jul 2026 16:21:06 +0200 Subject: [PATCH 20/29] Simplifications --- .../docs/design/oauth_usage_and_troubleshooting.md | 2 +- bfabric/src/bfabric/_oauth/credential_provider.py | 2 +- bfabric/src/bfabric/_oauth/webapp_client.py | 1 + bfabric/src/bfabric/bfabric.py | 1 + bfabric_scripts/docs/changelog.md | 2 +- .../src/bfabric_scripts/cli/login/_constants.py | 7 ++----- .../src/bfabric_scripts/cli/login/register.py | 1 + tests/bfabric/oauth/test_credential_provider.py | 13 +++++++++++++ tests/bfabric/oauth/test_webapp_client.py | 1 + tests/bfabric/test_bfabric.py | 1 + .../cli/login/test_login_constants.py | 10 ++-------- 11 files changed, 25 insertions(+), 16 deletions(-) diff --git a/bfabric/docs/design/oauth_usage_and_troubleshooting.md b/bfabric/docs/design/oauth_usage_and_troubleshooting.md index ead77ee6..64ef8aa3 100644 --- a/bfabric/docs/design/oauth_usage_and_troubleshooting.md +++ b/bfabric/docs/design/oauth_usage_and_troubleshooting.md @@ -77,7 +77,7 @@ token won't do — you need a user flow (PKCE or device code). The core `Bfabric` OAuth methods take `client_id` and `scope` as **required** arguments — there is no baked-in default. The `bfabric-cli` login commands (`auth login` / `auth device-code`) default to -the **`read-write`** scope preset (`api:write`, which implies `api:read`); `read-only` (`api:read`) +the **`read-only`** scope preset (`api:read`); `read-write` (`api:write`, which implies `api:read`) and `upload` (`api:write tus`) presets are also available, and any scope can be passed with `--scope`. These login presets are **minimal API scopes** — they do **not** include `groups` (the employee file-access path, see below) or the OIDC scopes, so request those explicitly when you need them. diff --git a/bfabric/src/bfabric/_oauth/credential_provider.py b/bfabric/src/bfabric/_oauth/credential_provider.py index d648f72e..dbc39402 100644 --- a/bfabric/src/bfabric/_oauth/credential_provider.py +++ b/bfabric/src/bfabric/_oauth/credential_provider.py @@ -65,7 +65,7 @@ def __init__( client_secret: str, token_url: str, *, - scope: str = "", + scope: str, token: dict[str, object] | None = None, grant_type: str = "client_credentials", token_cache_path: Path | None = None, diff --git a/bfabric/src/bfabric/_oauth/webapp_client.py b/bfabric/src/bfabric/_oauth/webapp_client.py index 5111ceee..7cf35a55 100644 --- a/bfabric/src/bfabric/_oauth/webapp_client.py +++ b/bfabric/src/bfabric/_oauth/webapp_client.py @@ -78,6 +78,7 @@ def create( client_id=client_id, client_secret=client_secret, token_url=token_url, + scope="", token=token_dict, grant_type="refresh_token", token_cache_path=user_token_cache_path, diff --git a/bfabric/src/bfabric/bfabric.py b/bfabric/src/bfabric/bfabric.py index 260d38c6..85727ec8 100644 --- a/bfabric/src/bfabric/bfabric.py +++ b/bfabric/src/bfabric/bfabric.py @@ -141,6 +141,7 @@ def _connect_oauth_from_config(cls, config_data: ConfigData) -> Bfabric: client_id=client_id, client_secret="", token_url=f"{base_url}/rest/oauth/token", + scope="", grant_type="refresh_token", token_cache_path=cache_path, ) diff --git a/bfabric_scripts/docs/changelog.md b/bfabric_scripts/docs/changelog.md index 91589eac..40612c5f 100644 --- a/bfabric_scripts/docs/changelog.md +++ b/bfabric_scripts/docs/changelog.md @@ -10,7 +10,7 @@ Versioning currently follows `X.Y.Z` semantic versioning, independent of the `bf ## \[Unreleased\] -- `auth login` / `auth device-code` now request a minimal, use-case-oriented scope by default: read-write (`api:write`, which implies `api:read`) instead of the previous broad `api:read api:write openid profile email groups`. Named presets are available — `read-only` (`api:read`), `read-write` (`api:write`), `upload` (`api:write tus`) — and any scope can still be passed explicitly via `--scope`. Client/webapp registration (`auth register` / `auth register-webapp`) keeps the OIDC-inclusive default, which webapps need for user-identity claims. +- `auth login` / `auth device-code` now request a minimal, use-case-oriented scope by default: read-only (`api:read`) instead of the previous broad `api:read api:write openid profile email groups`. Named presets are available — `read-only` (`api:read`), `read-write` (`api:write`), `upload` (`api:write tus`) — and any scope can still be passed explicitly via `--scope`. Client/webapp registration (`auth register` / `auth register-webapp`) keeps the OIDC-inclusive default, which webapps need for user-identity claims. ## \[1.16.0rc2\] - 2026-07-15 diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py index 0bb9e6c4..1b759ab0 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py @@ -36,8 +36,5 @@ class ScopePreset(NamedTuple): ScopePreset("upload", "api:write tus", "Upload files to the API (includes read and write API)"), ) -SCOPE_PRESETS_BY_NAME: dict[str, ScopePreset] = {preset.name: preset for preset in SCOPE_PRESETS} - -# The preset requested by default when the user does not pick one. -DEFAULT_SCOPE_PRESET = "read-write" -DEFAULT_LOGIN_SCOPE = SCOPE_PRESETS_BY_NAME[DEFAULT_SCOPE_PRESET].scope +# The first (least-privilege) preset is requested by default when the user does not pick one. +DEFAULT_LOGIN_SCOPE = SCOPE_PRESETS[0].scope diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/register.py b/bfabric_scripts/src/bfabric_scripts/cli/login/register.py index e332d672..95e1b8cf 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/register.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/register.py @@ -53,6 +53,7 @@ def _resolve_token_from_config(config_env: str, config_file: Path) -> tuple[str, client_id=client_id, client_secret="", token_url=token_url, + scope="", grant_type="refresh_token", token_cache_path=cache_path, ) diff --git a/tests/bfabric/oauth/test_credential_provider.py b/tests/bfabric/oauth/test_credential_provider.py index 5c68b4d2..c4f40efc 100644 --- a/tests/bfabric/oauth/test_credential_provider.py +++ b/tests/bfabric/oauth/test_credential_provider.py @@ -28,6 +28,7 @@ def provider(mock_oauth2_session): client_id="test-id", client_secret="test-secret", token_url="https://example.com/rest/oauth/token", + scope="", ) @@ -38,6 +39,7 @@ def test_client_credentials_requires_secret(self, mock_oauth2_session): client_id="test-id", client_secret="", token_url="https://example.com/rest/oauth/token", + scope="", grant_type="client_credentials", ) @@ -48,6 +50,7 @@ def test_refresh_token_allows_empty_secret(self, mock_oauth2_session): client_id="test-id", client_secret="", token_url="https://example.com/rest/oauth/token", + scope="", grant_type="refresh_token", token={"access_token": "t", "refresh_token": "rt", "expires_at": 9999999999}, ) @@ -101,6 +104,7 @@ def slow_fetch(*args, **kwargs): client_id="id", client_secret="secret", token_url="https://example.com/rest/oauth/token", + scope="", ) threads = [threading.Thread(target=provider.get_auth) for _ in range(5)] @@ -168,6 +172,7 @@ def test_refresh_token_preserved_for_refresh_grant(self, mock_oauth2_session): client_id="app-id", client_secret="", token_url="https://example.com/rest/oauth/token", + scope="", grant_type="refresh_token", token={"access_token": "jwt", "refresh_token": "keep_me", "expires_at": time.time() + 3600}, ) @@ -190,6 +195,7 @@ def test_seeded_token_with_refresh(self, mock_oauth2_session): client_id="app-id", client_secret="", token_url="https://example.com/rest/oauth/token", + scope="", token=token, grant_type="refresh_token", ) @@ -209,6 +215,7 @@ def test_session_configured_for_refresh(self, mock_oauth2_session, mocker): client_id="app-id", client_secret="", token_url="https://example.com/rest/oauth/token", + scope="", grant_type="refresh_token", token={"access_token": "jwt", "refresh_token": "rt", "expires_at": time.time() + 3600}, ) @@ -230,6 +237,7 @@ def test_refresh_failure_raises_bfabric_oauth_error(self, mock_oauth2_session): client_id="app-id", client_secret="", token_url="https://example.com/rest/oauth/token", + scope="", grant_type="refresh_token", token={"access_token": "stale", "refresh_token": "rt", "expires_at": 1}, ) @@ -260,6 +268,7 @@ def test_transport_failure_raises_bfabric_oauth_error(self, mock_oauth2_session) client_id="app-id", client_secret="", token_url="https://example.com/rest/oauth/token", + scope="", grant_type="refresh_token", token={"access_token": "stale", "refresh_token": "rt", "expires_at": 1}, ) @@ -282,6 +291,7 @@ def test_uses_disk_cache_on_init(self, tmp_path, mock_oauth2_session): client_id="id", client_secret="secret", token_url="https://example.com/rest/oauth/token", + scope="", token_cache_path=cache_path, ) @@ -300,6 +310,7 @@ def test_saves_to_disk_via_update_token_callback(self, tmp_path, mock_oauth2_ses client_id="id", client_secret="secret", token_url="https://example.com/rest/oauth/token", + scope="", token_cache_path=cache_path, ) # Extract the update_token callback that was passed to OAuth2Session @@ -328,6 +339,7 @@ def do_fetch(*args, **kwargs): client_id="id", client_secret="secret", token_url="https://example.com/rest/oauth/token", + scope="", token_cache_path=cache_path, ) provider.get_auth() @@ -348,6 +360,7 @@ def test_supplied_token_preferred_over_disk_cache(self, tmp_path, mock_oauth2_se client_id="id", client_secret="", token_url="https://example.com/rest/oauth/token", + scope="", token=supplied, grant_type="refresh_token", token_cache_path=cache_path, diff --git a/tests/bfabric/oauth/test_webapp_client.py b/tests/bfabric/oauth/test_webapp_client.py index 48bc3c18..394c3609 100644 --- a/tests/bfabric/oauth/test_webapp_client.py +++ b/tests/bfabric/oauth/test_webapp_client.py @@ -121,6 +121,7 @@ def test_creates_user_provider_with_refresh_token(self, mocker, mock_token_dict) client_id="cid", client_secret="csecret", token_url="https://bfabric.example.com/bfabric/rest/oauth/token", + scope="", token=mock_token_dict, grant_type="refresh_token", token_cache_path="/tmp/user_cache", diff --git a/tests/bfabric/test_bfabric.py b/tests/bfabric/test_bfabric.py index 3812b133..64419915 100644 --- a/tests/bfabric/test_bfabric.py +++ b/tests/bfabric/test_bfabric.py @@ -786,6 +786,7 @@ def test_oauth_only_client_survives_pickle(self, mocker): client_id="cid", client_secret="", token_url="https://example.com/bfabric/rest/oauth/token", + scope="", grant_type="refresh_token", token={"access_token": "tok-abc", "token_type": "Bearer", "expires_at": 9999999999}, ) diff --git a/tests/bfabric_scripts/cli/login/test_login_constants.py b/tests/bfabric_scripts/cli/login/test_login_constants.py index 45c061ae..b4780869 100644 --- a/tests/bfabric_scripts/cli/login/test_login_constants.py +++ b/tests/bfabric_scripts/cli/login/test_login_constants.py @@ -2,9 +2,7 @@ from bfabric_scripts.cli.login._constants import ( DEFAULT_LOGIN_SCOPE, - DEFAULT_SCOPE_PRESET, SCOPE_PRESETS, - SCOPE_PRESETS_BY_NAME, ) @@ -22,9 +20,5 @@ def test_presets_are_minimal_use_case_sets(self): def test_every_preset_has_a_description(self): assert all(preset.description for preset in SCOPE_PRESETS) - def test_by_name_index_covers_all_presets(self): - assert set(SCOPE_PRESETS_BY_NAME) == {preset.name for preset in SCOPE_PRESETS} - - def test_default_login_scope_is_read_write(self): - assert DEFAULT_SCOPE_PRESET == "read-write" - assert DEFAULT_LOGIN_SCOPE == SCOPE_PRESETS_BY_NAME["read-write"].scope == "api:write" + def test_default_login_scope_is_read_only(self): + assert DEFAULT_LOGIN_SCOPE == SCOPE_PRESETS[0].scope == "api:read" From e8a566377d920ce750bf594ee7a354d42775201f Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Thu, 16 Jul 2026 16:50:38 +0200 Subject: [PATCH 21/29] refactor(auth): require explicit --scope for CLI login (drop DEFAULT_LOGIN_SCOPE) Make --scope required on `auth login` / `auth device-code` and delete the DEFAULT_LOGIN_SCOPE constant, so the CLI no longer bakes in a default scope (matching the core library). Removes the [default: ...] hint from --help. SCOPE_PRESETS stays as the documented preset catalog. --- bfabric/docs/design/oauth_usage_and_troubleshooting.md | 6 +++--- bfabric_scripts/docs/changelog.md | 2 +- .../src/bfabric_scripts/cli/login/_constants.py | 3 --- .../src/bfabric_scripts/cli/login/device_code.py | 4 ++-- bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py | 4 ++-- tests/bfabric_scripts/cli/login/test_cmd_auth_login.py | 2 ++ .../cli/login/test_cmd_login_device_code.py | 2 ++ tests/bfabric_scripts/cli/login/test_login_constants.py | 8 +------- 8 files changed, 13 insertions(+), 18 deletions(-) diff --git a/bfabric/docs/design/oauth_usage_and_troubleshooting.md b/bfabric/docs/design/oauth_usage_and_troubleshooting.md index 64ef8aa3..4c63b6b1 100644 --- a/bfabric/docs/design/oauth_usage_and_troubleshooting.md +++ b/bfabric/docs/design/oauth_usage_and_troubleshooting.md @@ -76,9 +76,9 @@ like `containers`.** If a resource server authorizes by *your* container members token won't do — you need a user flow (PKCE or device code). The core `Bfabric` OAuth methods take `client_id` and `scope` as **required** arguments — there is -no baked-in default. The `bfabric-cli` login commands (`auth login` / `auth device-code`) default to -the **`read-only`** scope preset (`api:read`); `read-write` (`api:write`, which implies `api:read`) -and `upload` (`api:write tus`) presets are also available, and any scope can be passed with `--scope`. +no baked-in default. The `bfabric-cli` login commands (`auth login` / `auth device-code`) likewise +**require** an explicit `--scope`: pick a named preset — `read-only` (`api:read`), `read-write` +(`api:write`, which implies `api:read`), or `upload` (`api:write tus`) — or pass any scope string. These login presets are **minimal API scopes** — they do **not** include `groups` (the employee file-access path, see below) or the OIDC scopes, so request those explicitly when you need them. Client/webapp registration (`auth register` / `auth register-webapp`) keeps the broader diff --git a/bfabric_scripts/docs/changelog.md b/bfabric_scripts/docs/changelog.md index 40612c5f..089784a8 100644 --- a/bfabric_scripts/docs/changelog.md +++ b/bfabric_scripts/docs/changelog.md @@ -10,7 +10,7 @@ Versioning currently follows `X.Y.Z` semantic versioning, independent of the `bf ## \[Unreleased\] -- `auth login` / `auth device-code` now request a minimal, use-case-oriented scope by default: read-only (`api:read`) instead of the previous broad `api:read api:write openid profile email groups`. Named presets are available — `read-only` (`api:read`), `read-write` (`api:write`), `upload` (`api:write tus`) — and any scope can still be passed explicitly via `--scope`. Client/webapp registration (`auth register` / `auth register-webapp`) keeps the OIDC-inclusive default, which webapps need for user-identity claims. +- `auth login` / `auth device-code` now **require** an explicit `--scope` (no default). Named presets document the useful sets — `read-only` (`api:read`), `read-write` (`api:write`), `upload` (`api:write tus`) — or pass any scope string. Client/webapp registration (`auth register` / `auth register-webapp`) keeps the OIDC-inclusive default, which webapps need for user-identity claims. ## \[1.16.0rc2\] - 2026-07-15 diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py index 1b759ab0..3cce90fa 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py @@ -35,6 +35,3 @@ class ScopePreset(NamedTuple): ScopePreset("read-write", "api:write", "Write to the API (includes read from the API)"), ScopePreset("upload", "api:write tus", "Upload files to the API (includes read and write API)"), ) - -# The first (least-privilege) preset is requested by default when the user does not pick one. -DEFAULT_LOGIN_SCOPE = SCOPE_PRESETS[0].scope diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py index c7eb57b4..a72e5fde 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py @@ -12,16 +12,16 @@ from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_LOGIN_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID def cmd_login_device_code( base_url: Annotated[str, cyclopts.Parameter(help="B-Fabric instance URL.")], *, + scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")], client_id: Annotated[str, cyclopts.Parameter(help="OAuth client ID.")] = DEFAULT_CLIENT_ID, config_env: Annotated[str, cyclopts.Parameter(help="Environment name in the config file.")] = "PRODUCTION", config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_LOGIN_SCOPE, timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for authorization.")] = 600.0, set_default: Annotated[ bool, cyclopts.Parameter(help="Set this environment as the default in the config file.") diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py index 0678c3d8..ce33a68c 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py @@ -12,16 +12,16 @@ from bfabric._oauth.token_cache import compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE from bfabric.config.config_writer import write_environment_to_config -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, DEFAULT_LOGIN_SCOPE +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID def cmd_auth_login( base_url: Annotated[str, cyclopts.Parameter(help="B-Fabric instance URL.")], *, + scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")], client_id: Annotated[str, cyclopts.Parameter(help="OAuth client ID.")] = DEFAULT_CLIENT_ID, config_env: Annotated[str, cyclopts.Parameter(help="Environment name in the config file.")] = "PRODUCTION", config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - scope: Annotated[str, cyclopts.Parameter(help="OAuth scope.")] = DEFAULT_LOGIN_SCOPE, port: Annotated[int, cyclopts.Parameter(help="Local port for callback (0 = auto).")] = 0, timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for login.")] = 120.0, set_default: Annotated[ diff --git a/tests/bfabric_scripts/cli/login/test_cmd_auth_login.py b/tests/bfabric_scripts/cli/login/test_cmd_auth_login.py index 59b8e78d..9a8c708b 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_auth_login.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_auth_login.py @@ -25,6 +25,7 @@ def test_writes_config_and_caches_token(self, tmp_path, mocker): mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) cmd_auth_login( base_url="https://example.com/bfabric", + scope="api:read", client_id="test-client", config_env="PROD", config_file=config_file, @@ -52,6 +53,7 @@ def test_set_default_false_does_not_set_default(self, tmp_path, mocker): mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) cmd_auth_login( base_url="https://example.com/bfabric", + scope="api:read", client_id="test-client", config_env="PROD", config_file=config_file, diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py b/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py index b389131f..62520d0d 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py @@ -25,6 +25,7 @@ def test_writes_config_and_caches_token(self, tmp_path, mocker): mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) cmd_login_device_code( base_url="https://example.com/bfabric", + scope="api:read", client_id="test-client", config_env="PROD", config_file=config_file, @@ -52,6 +53,7 @@ def test_set_default_false_does_not_set_default(self, tmp_path, mocker): mocker.patch("bfabric._oauth.credential_provider.OAuth2Session", return_value=mock_session) cmd_login_device_code( base_url="https://example.com/bfabric", + scope="api:read", client_id="test-client", config_env="PROD", config_file=config_file, diff --git a/tests/bfabric_scripts/cli/login/test_login_constants.py b/tests/bfabric_scripts/cli/login/test_login_constants.py index b4780869..9dbef193 100644 --- a/tests/bfabric_scripts/cli/login/test_login_constants.py +++ b/tests/bfabric_scripts/cli/login/test_login_constants.py @@ -1,9 +1,6 @@ from __future__ import annotations -from bfabric_scripts.cli.login._constants import ( - DEFAULT_LOGIN_SCOPE, - SCOPE_PRESETS, -) +from bfabric_scripts.cli.login._constants import SCOPE_PRESETS class TestScopePresets: @@ -19,6 +16,3 @@ def test_presets_are_minimal_use_case_sets(self): def test_every_preset_has_a_description(self): assert all(preset.description for preset in SCOPE_PRESETS) - - def test_default_login_scope_is_read_only(self): - assert DEFAULT_LOGIN_SCOPE == SCOPE_PRESETS[0].scope == "api:read" From 179e4632af1c9a1a4a91aadf047f605ff26c182e Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Fri, 17 Jul 2026 09:33:42 +0200 Subject: [PATCH 22/29] Reduce verbosity --- .../src/bfabric_scripts/cli/interactive.py | 45 +++++-------------- .../src/bfabric_scripts/cli/login/_common.py | 42 +++++++---------- .../bfabric_scripts/cli/login/_constants.py | 19 ++++---- .../cli/login/default_config.py | 6 +-- .../src/bfabric_scripts/cli/login/logout.py | 14 +++--- .../src/bfabric_scripts/cli/login/pat.py | 6 +-- .../src/bfabric_scripts/cli/login/register.py | 3 +- .../cli/login/register_webapp.py | 5 +-- 8 files changed, 50 insertions(+), 90 deletions(-) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py index 6ae3488d..fc4d63d5 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/interactive.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/interactive.py @@ -1,8 +1,7 @@ -"""Reusable interactive prompt helpers built on ``questionary``. +"""Interactive prompt helpers built on ``questionary``. -These wrap the recurring CLI pattern where a value is selected from a menu or (optionally) -typed as a new value not in the list. Interactive prompts require a TTY; callers guard with -:func:`is_interactive` and handle the ``None`` a cancelled prompt returns. +Prompts require a TTY; callers guard with :func:`is_interactive` and handle the ``None`` +returned on cancel (or empty input, where noted). """ from __future__ import annotations @@ -15,7 +14,7 @@ def is_interactive() -> bool: - """Whether we can drive an interactive prompt (both ends attached to a terminal).""" + """Whether both stdin and stdout are attached to a terminal.""" return sys.stdin.isatty() and sys.stdout.isatty() @@ -27,58 +26,36 @@ def select_choice( describe: Callable[[str], str] | None = None, search: bool = False, ) -> str | None: - """Show an arrow-key menu of *choices* and return the picked value. + """Arrow-key menu over *choices*; returns the picked value or ``None`` on cancel. - Returns ``None`` if the user cancels (Ctrl-C). *default* pre-selects an entry and - must be one of *choices* (pass ``None`` otherwise). *describe* maps each value to the label - shown in the menu (e.g. to append a host or auth method); the return value is still the - plain choice, never its label. With *search*, the user can type to filter the list live - (arrow keys still work on the filtered subset) -- handy for long lists. + *default* must be one of *choices*. *describe* maps a value to its display label. *search* + lets the user type to filter. """ items: list[str | questionary.Choice] if describe is not None: items = [questionary.Choice(title=describe(choice), value=choice) for choice in choices] else: items = list(choices) - # questionary's ``ask()`` is typed ``Any``; every prompt here yields its value or None on cancel. - # With the search filter on, j/k must stop being navigation keys (they'd be swallowed as - # filter input) -- questionary raises otherwise. Arrow keys keep working regardless. return cast( "str | None", - questionary.select( - message, choices=items, default=default, use_search_filter=search, use_jk_keys=not search - ).ask(), + questionary.select(message, choices=items, default=default, use_search_filter=search, use_jk_keys=False).ask(), ) def select_or_input(message: str, choices: Sequence[str], *, default: str | None = None) -> str | None: - """Offer *choices* as autocomplete suggestions but let the user type a value not in the list. - - Returns the entered value, or ``None`` if the user cancels or submits an empty answer. - """ + """Autocomplete over *choices* that also accepts a value not in the list; ``None`` on cancel/empty.""" items = list(choices) - # Autocomplete only reveals suggestions on Tab, which isn't discoverable -- hint it, but only - # when there actually are suggestions to complete (a first-time prompt with no choices wouldn't). prompt = f"{message} (Tab to autocomplete)" if items else message answer = cast("str | None", questionary.autocomplete(prompt, choices=items, default=default or "").ask()) return answer or None def text_input(message: str, *, default: str = "") -> str | None: - """Prompt for a free-text value, prefilled with *default*. - - Returns the entered text, or ``None`` if the user cancels or submits an empty answer. - """ + """Free-text prompt prefilled with *default*; ``None`` on cancel/empty.""" answer = cast("str | None", questionary.text(message, default=default).ask()) return answer or None def confirm(message: str, *, default: bool = False) -> bool | None: - """Ask a yes/no question. - - Returns ``True``/``False`` for an explicit answer, or ``None`` if the user cancels (Ctrl-C) -- - questionary prints its own cancellation notice and yields ``None``. Like the other wrappers here, - cancellation surfaces as ``None`` so callers can tell an aborted prompt apart from a declined - "no" (a caller that wants to treat both alike can just check falsiness). - """ + """Yes/no prompt; ``True``/``False``, or ``None`` on cancel (distinct from a declined "no").""" return cast("bool | None", questionary.confirm(message, default=default).ask()) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py index 3946780e..e09d6c99 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py @@ -1,9 +1,7 @@ """Shared helpers for the ``auth`` command group. -Covers parameter resolution for the login commands (environment name and OAuth scope may be -given on the command line, picked interactively, or -- for the environment -- typed as a new -value) plus the rendering used by ``auth default`` / ``auth list`` / ``auth status``, so the -individual commands don't each re-implement it. +Parameter resolution (environment name, OAuth scope, set-default) plus the environment rendering +shared by ``auth default`` / ``auth list`` / ``auth status``. """ from __future__ import annotations @@ -29,10 +27,9 @@ def resolve_config_env(config_env: str | None, config_file: Path) -> str | None: """Resolve the target environment name. - * *config_env* given -> use it verbatim. - * no TTY -> the current default environment if set, else ``"PRODUCTION"``. - * otherwise -> interactive picker: choose an existing environment or type a new name, - prefilled with that same fallback. Returns ``None`` if the user cancels. + Explicit *config_env* wins; non-interactive falls back to the current default (else + ``"PRODUCTION"``); interactive picks an existing name or a typed new one, prefilled with that + fallback. ``None`` if cancelled. """ if config_env is not None: return config_env @@ -60,12 +57,10 @@ def _scope_menu_label(choice: str) -> str: def resolve_scope(scope: str | None) -> str | None: """Resolve the OAuth scope string. - * *scope* given -> a preset name expands to its scope string; anything else passes through - as a raw space-separated scope string. - * no TTY -> ``None``: there is no baked-in default scope, so a headless login must pass - ``--scope`` explicitly (the caller aborts). - * otherwise -> interactive picker of the named presets (least-privilege preselected) plus a - Custom option that opens a free-text prompt. Returns ``None`` if the user cancels. + A given *scope* expands preset names and passes anything else through as a raw scope string. + Non-interactive returns ``None`` (no default scope, so a headless login must pass ``--scope``; + the caller aborts). Interactive picks a preset (least-privilege preselected) or Custom + free-text. ``None`` if cancelled. """ if scope is not None: preset = SCOPE_PRESETS_BY_NAME.get(scope) @@ -88,9 +83,8 @@ def resolve_scope(scope: str | None) -> str | None: def resolve_set_default(set_default: bool | None, config_env: str) -> bool | None: """Resolve whether the freshly-authenticated environment becomes the config default. - * explicit ``--set-default`` / ``--no-set-default`` (i.e. not ``None``) -> honored verbatim. - * otherwise, no TTY -> ``True`` (the historical default). - * otherwise -> a yes/no prompt, preselected yes; ``None`` if the user cancels (the caller aborts). + Explicit *set_default* wins; non-interactive defaults to ``True``; interactive prompts (yes + preselected). ``None`` if cancelled (the caller aborts). """ if set_default is not None: return set_default @@ -107,9 +101,8 @@ def _normalize_scope(scope: str) -> str: def describe_scope(scope: object) -> str: """Render a granted OAuth scope for display, annotated with its preset name if it matches. - * a scope equal to a preset (order-insensitive) -> ``" []"`` - * any other non-empty scope -> the raw string - * missing / non-string (a cache without a recorded scope) -> ``"(not recorded)"`` + A preset match (order-insensitive) appends ``[]``; other non-empty scopes render raw; + missing / non-string input (a cache without a recorded scope) renders ``"(not recorded)"``. """ if not isinstance(scope, str) or not scope.strip(): return "(not recorded)" @@ -132,10 +125,8 @@ def _format_duration(seconds: float) -> str: def describe_token_cache(cached: dict[str, object] | None, *, now: float) -> str: - """Summarize a cached OAuth token's freshness. - - ``"missing"`` when absent; otherwise ``"present"``, extended with ``expired`` or - ``expires in ~…`` when the token carries a numeric ``expires_at`` (Unix seconds). + """Summarize a cached OAuth token's freshness: ``"missing"`` when absent, else ``"present"``, + extended with ``expired`` / ``expires in ~…`` when it carries a numeric ``expires_at``. """ if cached is None: return "missing" @@ -164,8 +155,7 @@ def environment_summary(env: EnvironmentConfig) -> str: def print_environments(console: Console, environments: dict[str, EnvironmentConfig], default: str | None) -> None: """List the configured environments with their host/auth summary, marking the default. - Rows are built as ``Text`` (not console markup) so a name containing "[" stays literal; the - current default gets a bold-green arrow so it stands out in a long list. + Rows are built as ``Text`` (not console markup) so a name containing "[" stays literal. """ console.print("Configuration environments:") width = max((len(name) for name in environments), default=0) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py index 8f2edb0f..ffd61ede 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py @@ -1,9 +1,8 @@ """Default OAuth client ID and scope policy for the CLI login commands. -These are CLI-level policy: ``"CLI"`` is the client identity the CLI registers -as, and the scopes below are the permission sets the CLI requests. The core -``bfabric`` library deliberately does not bake in these defaults — its OAuth -API requires callers to state ``client_id`` and ``scope`` explicitly. +CLI-level policy: ``"CLI"`` is the client identity the CLI registers as, and the scopes are the +permission sets it requests. The core ``bfabric`` library bakes in neither — its OAuth API +requires explicit ``client_id`` and ``scope``. """ from __future__ import annotations @@ -12,10 +11,9 @@ DEFAULT_CLIENT_ID = "CLI" -# Scope requested when *registering* an OAuth client/webapp (``auth register`` / -# ``auth register-webapp``). Broad and OIDC-inclusive on purpose: a registered webapp -# needs ``openid profile email groups`` for the user-identity claims it reads from the -# URL-token exchange, which the login presets below intentionally omit. +# Scope requested when *registering* an OAuth client/webapp. Broad and OIDC-inclusive on purpose: +# a registered webapp needs ``openid profile email groups`` for the user-identity claims it reads +# from the URL-token exchange, which the login presets below omit. DEFAULT_REGISTRATION_SCOPE = "api:read api:write openid profile email groups" @@ -27,9 +25,8 @@ class ScopePreset(NamedTuple): description: str -# Scope presets for interactive *login* (``auth login`` / ``auth device-code``), ordered -# by increasing capability. ``api:write`` implies ``api:read`` server-side, so the -# read-write set lists only ``api:write``; ``tus`` adds file-upload permission on top. +# Scope presets for interactive *login*, ordered by increasing capability. ``api:write`` implies +# ``api:read`` server-side, so read-write lists only ``api:write``; ``tus`` adds file upload. SCOPE_PRESETS: tuple[ScopePreset, ...] = ( ScopePreset("read-only", "api:read", "Read from the API"), ScopePreset("read-write", "api:write", "Write to the API (includes read from the API)"), diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py b/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py index f5beb85e..fdb03473 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py @@ -26,8 +26,8 @@ def cmd_auth_default( ) -> None: """Set the default configuration environment. - With no *config_env*, opens an interactive picker (arrow keys to navigate, Enter to select) - in a terminal, or lists the environments in a non-interactive context. + With no *config_env*, opens an interactive picker in a terminal, or lists the environments + non-interactively. """ config_path = Path(config_file).expanduser() if not config_path.is_file(): @@ -51,7 +51,7 @@ def cmd_auth_default( search=True, ) if config_env is None: - # None means either the user cancelled the picker or there is no TTY to prompt on. + # None: the user cancelled the picker, or there's no TTY to prompt on. console = Console() if is_interactive(): console.print("No changes made.") diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py b/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py index 3d4059f2..8b589be0 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py @@ -31,9 +31,8 @@ def cmd_login_logout( ) -> None: """Remove an environment: delete its config entry and clear any cached OAuth tokens. - With no *config_env*, opens an interactive picker in a terminal. Because this deletes - credentials, a non-interactive run must name the environment (``--config-env``) and pass - ``--no-confirm`` to skip the confirmation it cannot prompt for. + With no *config_env*, opens an interactive picker. A non-interactive run must name the + environment and pass ``--no-confirm`` (it cannot prompt for the destructive confirmation). """ config_path = Path(config_file).expanduser() if not config_path.is_file(): @@ -69,9 +68,8 @@ def cmd_login_logout( return env = environments[config_env] - # Removing the current default leaves the config with no default (a dangling default would make - # it unloadable, so it is cleared). Only worth flagging when other environments remain to - # default to; otherwise "no default" is moot. + # Removing the current default clears it (a dangling default makes the config unloadable). Only + # worth flagging when other environments remain to default to. leaves_no_default = config_env == config_file_obj.general.default_config and len(names) > 1 if not no_confirm: @@ -91,8 +89,8 @@ def cmd_login_logout( print("No changes made.") return - # Remove the config entry first: if that write fails, the cached token is left untouched so the - # environment stays fully usable, rather than losing its token to a half-completed removal. + # Remove the config entry first: if that write fails, the cached token is left intact so the + # environment stays usable, rather than half-removed. remove_environment_from_config(config_path, config_env) if env.auth_method == "oauth": client_id = env.client_id or DEFAULT_CLIENT_ID diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py b/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py index a124b631..7a74cee0 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/pat.py @@ -41,9 +41,9 @@ def cmd_login_pat( pat = getpass.getpass("Personal Access Token: ") else: print("Warning: passing secrets via CLI flags is insecure (visible in ps, shell history).", file=sys.stderr) - # Store the PAT under ``pat`` (not ``login``/``password``): a PAT is not 32 characters, and an - # unmodified <=1.19.0 client validates every environment eagerly and would reject a short - # password — poisoning the whole shared config file. Under ``pat`` old clients ignore it. + # Store under ``pat``, not ``login``/``password``: a PAT isn't 32 chars, so an old (<=1.19.0) + # client eagerly validating every environment would reject a short password and poison the + # shared config; old clients ignore an unknown ``pat`` key. env_data = { "base_url": base_url.rstrip("/"), "auth_method": "pat", diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/register.py b/bfabric_scripts/src/bfabric_scripts/cli/login/register.py index 95e1b8cf..d053066e 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/register.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/register.py @@ -16,9 +16,8 @@ def _resolve_token_from_config(config_env: str, config_file: Path) -> tuple[str, str]: - """Load a cached OAuth access token and base_url for the given environment. + """Load the cached OAuth access token and base_url for *config_env* as ``(token, base_url)``. - Returns ``(access_token, base_url)``. Raises :class:`SystemExit` on failure. """ import yaml diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py b/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py index 0d623764..6935f559 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/register_webapp.py @@ -29,10 +29,9 @@ def cmd_login_register_webapp( technology_id: Annotated[int | None, cyclopts.Parameter(help="Technology ID for the application.")] = None, description: Annotated[str | None, cyclopts.Parameter(help="Application description.")] = None, ) -> None: - """Register a new OAuth webapp: create OAuth client and B-Fabric application. + """Register a new OAuth webapp: create the OAuth client and the B-Fabric application. - Uses the current config environment's credentials for both the OAuth - registration endpoint (Bearer token) and the SOAP application save. + Uses the config environment's credentials for both the registration endpoint and the SOAP save. """ from bfabric import Bfabric from bfabric._oauth.registration import register_webapp From 4a6cb176642dee95e1f1b626fac4bb45b192366f Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Fri, 17 Jul 2026 09:54:38 +0200 Subject: [PATCH 23/29] Update config_writer.py --- bfabric/src/bfabric/config/config_writer.py | 80 ++++++++------------- 1 file changed, 30 insertions(+), 50 deletions(-) diff --git a/bfabric/src/bfabric/config/config_writer.py b/bfabric/src/bfabric/config/config_writer.py index 9e6e3c52..ba6b399e 100644 --- a/bfabric/src/bfabric/config/config_writer.py +++ b/bfabric/src/bfabric/config/config_writer.py @@ -1,7 +1,6 @@ """Write environment entries to the bfabricpy YAML config file. -Note: ``yaml.dump`` does not preserve YAML comments — any comments in the -existing config file will be lost when the file is rewritten. +Note: rewriting the file drops any YAML comments in it (``yaml.dump`` doesn't preserve them). """ from __future__ import annotations @@ -20,12 +19,11 @@ def _write_config_file(config_path: Path, data: Mapping[str, object]) -> None: - """Serialize *data* to *config_path* as YAML with ``0o600`` permissions. - - Centralizes the secret-safe write shared by every config mutation: creates parent - directories, dumps the mapping, and forces ``0o600`` even on a pre-existing file (whose - permissions ``os.open`` would otherwise leave untouched) so a secret never lands in a + """Serialize *data* to *config_path* as YAML, always ``0o600`` so a secret never lands in a group/world-readable file. + + The explicit ``fchmod`` forces the mode even on a pre-existing file, whose permissions + ``os.open`` would otherwise leave untouched. """ config_path = Path(config_path).expanduser() config_path.parent.mkdir(parents=True, exist_ok=True) @@ -39,16 +37,12 @@ def _write_config_file(config_path: Path, data: Mapping[str, object]) -> None: def _validate_round_trip(env_name: str, env_data: Mapping[str, object]) -> None: - """Reject an environment that the reader could not load back. + """Reject an environment the reader could not load back, so a write either persists a parseable + config or fails without touching the file. - Mirrors the reader's invariants so a write either persists a parseable config or fails - cleanly without touching the file. Checks two things: - - * The name is not reserved -- the reader treats ``GENERAL`` as the general section and - forbids ``default``, so neither can be an environment. - * The fields form a valid :class:`EnvironmentConfig` (e.g. ``base_url`` present, auth - combination consistent). Only the single environment is validated, not the merged file, - so a pre-existing legacy environment can't block writing a new, valid one. + Rejects the reserved names (``GENERAL`` is the general section, ``default`` is forbidden) and + anything that isn't a valid :class:`EnvironmentConfig`. Only this one environment is validated, + not the merged file, so a pre-existing legacy environment can't block writing a new valid one. """ if env_name in ("GENERAL", "default"): raise ValueError(f"Environment name {env_name!r} is reserved and cannot be used.") @@ -62,26 +56,19 @@ def write_environment_to_config( *, set_default: bool, ) -> None: - """Write (or update) an environment section in the bfabricpy YAML config. - - * Creates the file if it does not exist. - * Merges *env_data* into the target environment, preserving other - environments. - * Sets ``GENERAL.default_config`` to *env_name* when *set_default* is - ``True``. - * File permissions are set to ``0o600``. + """Write (or update) an environment section in the bfabricpy YAML config, creating the file + (mode ``0o600``) if needed and preserving other environments. Sets ``GENERAL.default_config`` + to *env_name* when *set_default*. :param config_path: Path to the YAML config file (will be expanded). :param env_name: Name of the environment to create / update. - :param env_data: Dictionary of fields for the environment. - :param set_default: If ``True``, set this environment as the default. Required: callers must - decide explicitly whether the new environment becomes the default. - :raises pydantic.ValidationError: If *env_data* would not parse back through the reader - (e.g. a missing ``base_url`` or an invalid auth combination). Validated before any - filesystem change, so a rejected write leaves an existing config untouched. + :param env_data: Fields for the environment. + :param set_default: Whether this environment becomes the default. Required: callers must decide + explicitly. + :raises pydantic.ValidationError: If *env_data* would not parse back through the reader (e.g. a + missing ``base_url`` or an invalid auth combination). Checked before any filesystem change, + so a rejected write leaves an existing config untouched. """ - # Guarantee the round-trip before any filesystem change, so a rejected write leaves an - # existing config untouched. _validate_round_trip(env_name, env_data) config_path = Path(config_path).expanduser() @@ -108,15 +95,13 @@ def write_environment_to_config( def set_default_config(config_path: Path, env_name: str) -> None: """Set ``GENERAL.default_config`` to an already-defined environment. - Unlike :func:`write_environment_to_config`, this only flips the default -- it never - creates or modifies an environment. Other environments and the general section are - preserved. + Only flips the default; never creates or modifies an environment. :param config_path: Path to the YAML config file (will be expanded). :param env_name: Name of an existing environment to mark as default. :raises FileNotFoundError: If the config file does not exist. - :raises ValueError: If *env_name* is not among the configured environments; the file is - left untouched in that case. + :raises ValueError: If *env_name* is not among the configured environments; the file is left + untouched. """ config_path = Path(config_path).expanduser() if not config_path.is_file(): @@ -126,13 +111,10 @@ def set_default_config(config_path: Path, env_name: str) -> None: existing: dict[str, object] existing = loaded if isinstance(loaded, dict) else {} # pyright: ignore[reportUnknownVariableType] - # Enumerate the configured environments through the reader so the check matches how the - # file will actually load back. ConfigFile's "before" validators mutate their input in - # place, so always validate a deep copy and keep ``existing`` pristine for the write. - # This also doubles as the round-trip guard: since env_name is confirmed to be one of - # config_file_obj.environments, and environments are otherwise untouched below, setting - # GENERAL.default_config to env_name cannot fail ConfigFile's own default-config-must-exist - # validator -- so no second validation pass is needed after the mutation. + # Validate through the reader (on a deep copy — ConfigFile's "before" validators mutate their + # input in place) so the membership check matches how the file loads back, keeping ``existing`` + # pristine for the write. This doubles as the round-trip guard: env_name is a known environment + # and the environments are untouched below, so setting the default can't fail the reader after. config_file_obj = ConfigFile.model_validate(copy.deepcopy(existing)) if env_name not in config_file_obj.environments: available = ", ".join(sorted(config_file_obj.environments)) or "(none)" @@ -149,16 +131,14 @@ def set_default_config(config_path: Path, env_name: str) -> None: def remove_environment_from_config(config_path: Path, env_name: str) -> None: """Delete an environment section from the bfabricpy YAML config. - Removes the top-level *env_name* section and, if ``GENERAL.default_config`` pointed at it, - clears that default -- otherwise the reader (:class:`ConfigFile`) would refuse to load a file - whose default names a missing environment. Other environments and general settings are - preserved. + Also clears ``GENERAL.default_config`` if it pointed at *env_name* — otherwise the reader would + refuse to load a file whose default names a missing environment. :param config_path: Path to the YAML config file (will be expanded). :param env_name: Name of an existing environment to remove. :raises FileNotFoundError: If the config file does not exist. - :raises ValueError: If *env_name* is not among the configured environments; the file is - left untouched in that case. + :raises ValueError: If *env_name* is not among the configured environments; the file is left + untouched. """ config_path = Path(config_path).expanduser() if not config_path.is_file(): From e83fc5c0039f9b754a11ff4ab1bad55ec6a9a70c Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Fri, 17 Jul 2026 10:30:51 +0200 Subject: [PATCH 24/29] test(auth): assert select_choice always disables j/k navigation keys The prompt kit always passes use_jk_keys=False (arrow keys are the sole navigation), but the select_choice test still asserted True. It lives under cli/ rather than cli/login/, so the login test runs never caught it. --- tests/bfabric_scripts/cli/test_interactive.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/bfabric_scripts/cli/test_interactive.py b/tests/bfabric_scripts/cli/test_interactive.py index 98198a4b..6a783d15 100644 --- a/tests/bfabric_scripts/cli/test_interactive.py +++ b/tests/bfabric_scripts/cli/test_interactive.py @@ -11,8 +11,10 @@ def test_delegates_to_questionary_select(self, mocker): question.ask.return_value = "TEST" select = mocker.patch("bfabric_scripts.cli.interactive.questionary.select", return_value=question) assert select_choice("Pick", ["PROD", "TEST"], default="TEST") == "TEST" + # j/k are always disabled so ↑/↓ are the sole navigation keys (and never clash with the + # search filter, which questionary would otherwise reject). select.assert_called_once_with( - "Pick", choices=["PROD", "TEST"], default="TEST", use_search_filter=False, use_jk_keys=True + "Pick", choices=["PROD", "TEST"], default="TEST", use_search_filter=False, use_jk_keys=False ) def test_describe_builds_labelled_choices_but_returns_plain_value(self, mocker): @@ -31,7 +33,7 @@ def test_search_flag_enables_live_filter(self, mocker): select = mocker.patch("bfabric_scripts.cli.interactive.questionary.select", return_value=question) select_choice("Pick", ["PROD", "TEST"], search=True) assert select.call_args.kwargs["use_search_filter"] is True - # j/k must be released as navigation keys so they can be typed into the filter. + # j/k stay off (as always) so they don't clash with typing into the search filter. assert select.call_args.kwargs["use_jk_keys"] is False From 34b45f314d7f802afd0ee5b1636974cbf4da16d2 Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Fri, 17 Jul 2026 10:31:20 +0200 Subject: [PATCH 25/29] refactor(auth): merge pkce + device-code logins into one module cmd_auth_login (browser/PKCE) and cmd_login_device_code were ~90% identical: same resolve-or-abort preamble and same token-persist + config-write tail. Merge them into cli/login/oauth_login.py with two local helpers (_resolve_params, _persist) and shared cyclopts help constants. Cuts ~45 production lines and one module without loading _common (behavior and --help output unchanged). --- bfabric/docs/design/oauth_integration.md | 4 +- .../src/bfabric_scripts/cli/cli_auth.py | 3 +- .../bfabric_scripts/cli/login/device_code.py | 83 ------------ .../bfabric_scripts/cli/login/oauth_login.py | 124 ++++++++++++++++++ .../src/bfabric_scripts/cli/login/pkce.py | 87 ------------ .../cli/login/test_cmd_auth_login.py | 14 +- .../cli/login/test_cmd_login_device_code.py | 10 +- 7 files changed, 139 insertions(+), 186 deletions(-) delete mode 100644 bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py create mode 100644 bfabric_scripts/src/bfabric_scripts/cli/login/oauth_login.py delete mode 100644 bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py diff --git a/bfabric/docs/design/oauth_integration.md b/bfabric/docs/design/oauth_integration.md index efa8d125..3c798b51 100644 --- a/bfabric/docs/design/oauth_integration.md +++ b/bfabric/docs/design/oauth_integration.md @@ -45,8 +45,8 @@ All commands registered under `bfabric-cli auth` via cyclopts. | Command | File | Description | |---------|------|-------------| -| `auth login ` | `cli/login/pkce.py` | Browser-based OAuth login. Caches tokens + writes config. | -| `auth device-code ` | `cli/login/device_code.py` | Headless OAuth login. | +| `auth login ` | `cli/login/oauth_login.py` | Browser-based OAuth login. Caches tokens + writes config. | +| `auth device-code ` | `cli/login/oauth_login.py` | Headless OAuth login. | | `auth pat ` | `cli/login/pat.py` | Personal Access Token login. | | `auth register ` | `cli/login/register.py` | RFC 7591 dynamic client registration. Outputs JSON. | | `auth status` | `cli/login/status.py` | Show current auth status for an environment. | diff --git a/bfabric_scripts/src/bfabric_scripts/cli/cli_auth.py b/bfabric_scripts/src/bfabric_scripts/cli/cli_auth.py index cd25a849..c3364e22 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/cli_auth.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/cli_auth.py @@ -1,11 +1,10 @@ import cyclopts from bfabric_scripts.cli.login.default_config import cmd_auth_default -from bfabric_scripts.cli.login.device_code import cmd_login_device_code from bfabric_scripts.cli.login.list import cmd_auth_list from bfabric_scripts.cli.login.logout import cmd_login_logout +from bfabric_scripts.cli.login.oauth_login import cmd_auth_login, cmd_login_device_code from bfabric_scripts.cli.login.pat import cmd_login_pat -from bfabric_scripts.cli.login.pkce import cmd_auth_login from bfabric_scripts.cli.login.register import cmd_login_register from bfabric_scripts.cli.login.register_webapp import cmd_login_register_webapp from bfabric_scripts.cli.login.status import cmd_login_status diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py b/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py deleted file mode 100644 index d1b909b0..00000000 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Device-code login command.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Annotated - -import cyclopts - -from bfabric._oauth.credential_provider import OAuthCredentialProvider -from bfabric._oauth.device_code import device_code_login -from bfabric._oauth.token_cache import compute_token_cache_path -from bfabric.config import DEFAULT_CONFIG_FILE -from bfabric.config.config_writer import write_environment_to_config -from bfabric_scripts.cli.login._common import resolve_config_env, resolve_scope, resolve_set_default -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID - - -def cmd_login_device_code( - base_url: Annotated[str, cyclopts.Parameter(help="B-Fabric instance URL.")], - *, - client_id: Annotated[str, cyclopts.Parameter(help="OAuth client ID.")] = DEFAULT_CLIENT_ID, - config_env: Annotated[ - str | None, cyclopts.Parameter(help="Environment name (interactive picker: existing or new, if omitted).") - ] = None, - config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - scope: Annotated[ - str | None, - cyclopts.Parameter( - help="OAuth scope preset (read-only | read-write | upload) or a raw scope string " - "(interactive picker if omitted)." - ), - ] = None, - timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for authorization.")] = 600.0, - set_default: Annotated[ - bool | None, - cyclopts.Parameter(help="Set this environment as the default in the config file (prompted if omitted)."), - ] = None, -) -> None: - """Authenticate via device code flow (for headless environments).""" - import sys - - config_env = resolve_config_env(config_env, config_file) - scope = resolve_scope(scope) - if config_env is None or scope is None: - print("Login aborted.", file=sys.stderr) - return - set_default = resolve_set_default(set_default, config_env) - if set_default is None: - print("Login aborted.", file=sys.stderr) - return - - base_url = base_url.rstrip("/") - try: - token = device_code_login( - base_url, - client_id=client_id, - scope=scope, - timeout=timeout, - ) - except RuntimeError as e: - print(f"Error: {e}", file=sys.stderr) - raise SystemExit(1) from None - cache_path = compute_token_cache_path(base_url, client_id, config_env).expanduser() - token_url = f"{base_url}/rest/oauth/token" - # Constructed for its side effect: persists the fresh token to the disk cache. - _ = OAuthCredentialProvider( - client_id=client_id, - client_secret="", - token_url=token_url, - token=token, - grant_type="refresh_token", - scope=scope, - token_cache_path=cache_path, - ) - env_data = { - "base_url": base_url, - "auth_method": "oauth", - "client_id": client_id, - } - write_environment_to_config(config_file, config_env, env_data, set_default=set_default) - print(f"Authenticated successfully.") - print(f"Config saved to environment '{config_env}' in {config_file}") diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/oauth_login.py b/bfabric_scripts/src/bfabric_scripts/cli/login/oauth_login.py new file mode 100644 index 00000000..5d88864e --- /dev/null +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/oauth_login.py @@ -0,0 +1,124 @@ +"""Interactive OAuth login commands: browser (PKCE) and device-code flows. + +Both resolve the same env/scope/set-default parameters and persist the resulting token +identically; they differ only in how the token is obtained (``_resolve_params`` / ``_persist`` +hold the shared parts). +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Annotated + +import cyclopts + +from bfabric._oauth.credential_provider import OAuthCredentialProvider +from bfabric._oauth.device_code import device_code_login +from bfabric._oauth.pkce import pkce_login +from bfabric._oauth.token_cache import compute_token_cache_path +from bfabric.config import DEFAULT_CONFIG_FILE +from bfabric.config.config_writer import write_environment_to_config +from bfabric_scripts.cli.login._common import resolve_config_env, resolve_scope, resolve_set_default +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID + +_SCOPE_HELP = ( + "OAuth scope preset (read-only | read-write | upload) or a raw scope string " "(interactive picker if omitted)." +) +_CONFIG_ENV_HELP = "Environment name (interactive picker: existing or new, if omitted)." +_SET_DEFAULT_HELP = "Set this environment as the default in the config file (prompted if omitted)." + + +def _resolve_params( + config_env: str | None, scope: str | None, set_default: bool | None, config_file: Path +) -> tuple[str, str, bool] | None: + """Resolve env/scope/set-default, or return ``None`` (after printing "Login aborted.") on cancel.""" + config_env = resolve_config_env(config_env, config_file) + scope = resolve_scope(scope) + if config_env is None or scope is None: + print("Login aborted.", file=sys.stderr) + return None + set_default = resolve_set_default(set_default, config_env) + if set_default is None: + print("Login aborted.", file=sys.stderr) + return None + return config_env, scope, set_default + + +def _persist( + base_url: str, + client_id: str, + scope: str, + token: dict[str, object], + config_env: str, + config_file: Path, + set_default: bool, +) -> None: + """Cache the fresh OAuth *token* and record the environment in the config.""" + # OAuthCredentialProvider is constructed for its side effect: writing the token to the disk cache. + _ = OAuthCredentialProvider( + client_id=client_id, + client_secret="", + token_url=f"{base_url}/rest/oauth/token", + token=token, + grant_type="refresh_token", + scope=scope, + token_cache_path=compute_token_cache_path(base_url, client_id, config_env).expanduser(), + ) + env_data = {"base_url": base_url, "auth_method": "oauth", "client_id": client_id} + write_environment_to_config(config_file, config_env, env_data, set_default=set_default) + print("Authenticated successfully.") + print(f"Config saved to environment '{config_env}' in {config_file}") + + +def cmd_auth_login( + base_url: Annotated[str, cyclopts.Parameter(help="B-Fabric instance URL.")], + *, + client_id: Annotated[str, cyclopts.Parameter(help="OAuth client ID.")] = DEFAULT_CLIENT_ID, + config_env: Annotated[str | None, cyclopts.Parameter(help=_CONFIG_ENV_HELP)] = None, + config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, + scope: Annotated[str | None, cyclopts.Parameter(help=_SCOPE_HELP)] = None, + port: Annotated[int, cyclopts.Parameter(help="Local port for callback (0 = auto).")] = 0, + timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for login.")] = 120.0, + set_default: Annotated[bool | None, cyclopts.Parameter(help=_SET_DEFAULT_HELP)] = None, +) -> None: + """Authenticate via browser-based login (OAuth PKCE flow).""" + resolved = _resolve_params(config_env, scope, set_default, config_file) + if resolved is None: + return + config_env, scope, set_default = resolved + + base_url = base_url.rstrip("/") + print("Opening browser for authentication...", file=sys.stderr) + print("Waiting for login to complete...", file=sys.stderr) + try: + token = pkce_login(base_url, client_id=client_id, scope=scope, port=port, timeout=timeout) + except RuntimeError as e: + print(f"Error: {e}", file=sys.stderr) + raise SystemExit(1) from None + _persist(base_url, client_id, scope, token, config_env, config_file, set_default) + + +def cmd_login_device_code( + base_url: Annotated[str, cyclopts.Parameter(help="B-Fabric instance URL.")], + *, + client_id: Annotated[str, cyclopts.Parameter(help="OAuth client ID.")] = DEFAULT_CLIENT_ID, + config_env: Annotated[str | None, cyclopts.Parameter(help=_CONFIG_ENV_HELP)] = None, + config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, + scope: Annotated[str | None, cyclopts.Parameter(help=_SCOPE_HELP)] = None, + timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for authorization.")] = 600.0, + set_default: Annotated[bool | None, cyclopts.Parameter(help=_SET_DEFAULT_HELP)] = None, +) -> None: + """Authenticate via device code flow (for headless environments).""" + resolved = _resolve_params(config_env, scope, set_default, config_file) + if resolved is None: + return + config_env, scope, set_default = resolved + + base_url = base_url.rstrip("/") + try: + token = device_code_login(base_url, client_id=client_id, scope=scope, timeout=timeout) + except RuntimeError as e: + print(f"Error: {e}", file=sys.stderr) + raise SystemExit(1) from None + _persist(base_url, client_id, scope, token, config_env, config_file, set_default) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py b/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py deleted file mode 100644 index 14771546..00000000 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/pkce.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Browser-based login command (OAuth PKCE flow).""" - -from __future__ import annotations - -from pathlib import Path -from typing import Annotated - -import cyclopts - -from bfabric._oauth.credential_provider import OAuthCredentialProvider -from bfabric._oauth.pkce import pkce_login -from bfabric._oauth.token_cache import compute_token_cache_path -from bfabric.config import DEFAULT_CONFIG_FILE -from bfabric.config.config_writer import write_environment_to_config -from bfabric_scripts.cli.login._common import resolve_config_env, resolve_scope, resolve_set_default -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID - - -def cmd_auth_login( - base_url: Annotated[str, cyclopts.Parameter(help="B-Fabric instance URL.")], - *, - client_id: Annotated[str, cyclopts.Parameter(help="OAuth client ID.")] = DEFAULT_CLIENT_ID, - config_env: Annotated[ - str | None, cyclopts.Parameter(help="Environment name (interactive picker: existing or new, if omitted).") - ] = None, - config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - scope: Annotated[ - str | None, - cyclopts.Parameter( - help="OAuth scope preset (read-only | read-write | upload) or a raw scope string " - "(interactive picker if omitted)." - ), - ] = None, - port: Annotated[int, cyclopts.Parameter(help="Local port for callback (0 = auto).")] = 0, - timeout: Annotated[float, cyclopts.Parameter(help="Seconds to wait for login.")] = 120.0, - set_default: Annotated[ - bool | None, - cyclopts.Parameter(help="Set this environment as the default in the config file (prompted if omitted)."), - ] = None, -) -> None: - """Authenticate via browser-based login (OAuth PKCE flow).""" - import sys - - config_env = resolve_config_env(config_env, config_file) - scope = resolve_scope(scope) - if config_env is None or scope is None: - print("Login aborted.", file=sys.stderr) - return - set_default = resolve_set_default(set_default, config_env) - if set_default is None: - print("Login aborted.", file=sys.stderr) - return - - base_url = base_url.rstrip("/") - print("Opening browser for authentication...", file=sys.stderr) - print("Waiting for login to complete...", file=sys.stderr) - try: - token = pkce_login( - base_url, - client_id=client_id, - scope=scope, - port=port, - timeout=timeout, - ) - except RuntimeError as e: - print(f"Error: {e}", file=sys.stderr) - raise SystemExit(1) from None - cache_path = compute_token_cache_path(base_url, client_id, config_env).expanduser() - token_url = f"{base_url}/rest/oauth/token" - # Constructed for its side effect: persists the fresh token to the disk cache. - _ = OAuthCredentialProvider( - client_id=client_id, - client_secret="", - token_url=token_url, - token=token, - grant_type="refresh_token", - scope=scope, - token_cache_path=cache_path, - ) - env_data = { - "base_url": base_url, - "auth_method": "oauth", - "client_id": client_id, - } - write_environment_to_config(config_file, config_env, env_data, set_default=set_default) - print(f"Authenticated successfully.") - print(f"Config saved to environment '{config_env}' in {config_file}") diff --git a/tests/bfabric_scripts/cli/login/test_cmd_auth_login.py b/tests/bfabric_scripts/cli/login/test_cmd_auth_login.py index 26daf188..505a4cd1 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_auth_login.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_auth_login.py @@ -3,13 +3,13 @@ import yaml from bfabric_scripts.cli.login._constants import SCOPE_PRESETS_BY_NAME -from bfabric_scripts.cli.login.pkce import cmd_auth_login +from bfabric_scripts.cli.login.oauth_login import cmd_auth_login class TestCmdAuthLogin: def test_writes_config_and_caches_token(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" - mock_pkce = mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=oauth_token) + mock_pkce = mocker.patch("bfabric_scripts.cli.login.oauth_login.pkce_login", return_value=oauth_token) cmd_auth_login( base_url="https://example.com/bfabric", scope="api:read", @@ -26,7 +26,7 @@ def test_writes_config_and_caches_token(self, tmp_path, mocker, oauth_token, oau def test_set_default_false_does_not_set_default(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" - mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=oauth_token) + mocker.patch("bfabric_scripts.cli.login.oauth_login.pkce_login", return_value=oauth_token) cmd_auth_login( base_url="https://example.com/bfabric", scope="api:read", @@ -42,7 +42,7 @@ def test_set_default_false_does_not_set_default(self, tmp_path, mocker, oauth_to def test_prompts_for_default_when_omitted(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" - mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=oauth_token) + mocker.patch("bfabric_scripts.cli.login.oauth_login.pkce_login", return_value=oauth_token) # No --set-default given: in a terminal the user is asked; here they decline. mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) confirm = mocker.patch("bfabric_scripts.cli.login._common.confirm", return_value=False) @@ -61,7 +61,7 @@ def test_prompts_for_default_when_omitted(self, tmp_path, mocker, oauth_token, o def test_cancel_at_set_default_aborts(self, tmp_path, mocker, capsys): config_file = tmp_path / "config.yml" - mock_pkce = mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login") + mock_pkce = mocker.patch("bfabric_scripts.cli.login.oauth_login.pkce_login") # No --set-default given: the user reaches the confirm prompt and cancels it (Ctrl-C -> None). mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) mocker.patch("bfabric_scripts.cli.login._common.confirm", return_value=None) @@ -79,7 +79,7 @@ def test_cancel_at_set_default_aborts(self, tmp_path, mocker, capsys): def test_scope_preset_is_expanded(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" - mock_pkce = mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=oauth_token) + mock_pkce = mocker.patch("bfabric_scripts.cli.login.oauth_login.pkce_login", return_value=oauth_token) cmd_auth_login( base_url="https://example.com/bfabric", client_id="test-client", @@ -100,7 +100,7 @@ def test_config_env_omitted_falls_back_to_current_default(self, tmp_path, mocker } ) ) - mocker.patch("bfabric_scripts.cli.login.pkce.pkce_login", return_value=oauth_token) + mocker.patch("bfabric_scripts.cli.login.oauth_login.pkce_login", return_value=oauth_token) # No TTY (pytest) and no --config-env => reuse the current default env, not PRODUCTION. mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=False) cmd_auth_login(base_url="https://example.com/bfabric", client_id="c", config_file=config_file, scope="api:read") diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py b/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py index 37f49e35..54866d19 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_device_code.py @@ -3,13 +3,13 @@ import yaml from bfabric_scripts.cli.login._constants import SCOPE_PRESETS_BY_NAME -from bfabric_scripts.cli.login.device_code import cmd_login_device_code +from bfabric_scripts.cli.login.oauth_login import cmd_login_device_code class TestCmdLoginDeviceCode: def test_writes_config_and_caches_token(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" - mock_dc = mocker.patch("bfabric_scripts.cli.login.device_code.device_code_login", return_value=oauth_token) + mock_dc = mocker.patch("bfabric_scripts.cli.login.oauth_login.device_code_login", return_value=oauth_token) cmd_login_device_code( base_url="https://example.com/bfabric", scope="api:read", @@ -26,7 +26,7 @@ def test_writes_config_and_caches_token(self, tmp_path, mocker, oauth_token, oau def test_set_default_false_does_not_set_default(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" - mocker.patch("bfabric_scripts.cli.login.device_code.device_code_login", return_value=oauth_token) + mocker.patch("bfabric_scripts.cli.login.oauth_login.device_code_login", return_value=oauth_token) cmd_login_device_code( base_url="https://example.com/bfabric", scope="api:read", @@ -42,7 +42,7 @@ def test_set_default_false_does_not_set_default(self, tmp_path, mocker, oauth_to def test_cancel_at_set_default_aborts(self, tmp_path, mocker, capsys): config_file = tmp_path / "config.yml" - mock_dc = mocker.patch("bfabric_scripts.cli.login.device_code.device_code_login") + mock_dc = mocker.patch("bfabric_scripts.cli.login.oauth_login.device_code_login") # No --set-default given: the user reaches the confirm prompt and cancels it (Ctrl-C -> None). mocker.patch("bfabric_scripts.cli.login._common.is_interactive", return_value=True) mocker.patch("bfabric_scripts.cli.login._common.confirm", return_value=None) @@ -60,7 +60,7 @@ def test_cancel_at_set_default_aborts(self, tmp_path, mocker, capsys): def test_scope_preset_is_expanded(self, tmp_path, mocker, oauth_token, oauth_session): config_file = tmp_path / "config.yml" - mock_dc = mocker.patch("bfabric_scripts.cli.login.device_code.device_code_login", return_value=oauth_token) + mock_dc = mocker.patch("bfabric_scripts.cli.login.oauth_login.device_code_login", return_value=oauth_token) cmd_login_device_code( base_url="https://example.com/bfabric", client_id="test-client", From 73f516c7c9d3d0c9d4cbe378709e9b7a51081c50 Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Fri, 17 Jul 2026 10:47:45 +0200 Subject: [PATCH 26/29] refactor(auth): consolidate env-management commands into manage.py list / default / status / logout all load the config file and act on a named environment, and duplicated the config-load block (x4) and the interactive env picker (x2). Merge the four into cli/login/manage.py with local _load_config and _select_environment helpers, and move the display helpers (environment_summary, print_environments, describe_scope, describe_token_cache) there from _common, since these commands are their only consumers. _common.py is now single-purpose (login-parameter resolution); the login package drops from 11 modules to 8 and ~48 lines. Behavior and --help output unchanged. --- .../src/bfabric_scripts/cli/cli_auth.py | 5 +- .../src/bfabric_scripts/cli/login/_common.py | 93 +----- .../cli/login/default_config.py | 68 ---- .../src/bfabric_scripts/cli/login/list.py | 32 -- .../src/bfabric_scripts/cli/login/logout.py | 102 ------ .../src/bfabric_scripts/cli/login/manage.py | 297 ++++++++++++++++++ .../src/bfabric_scripts/cli/login/status.py | 60 ---- .../cli/login/test_cmd_auth_default.py | 12 +- .../cli/login/test_cmd_auth_list.py | 2 +- .../cli/login/test_cmd_login_logout.py | 26 +- .../cli/login/test_cmd_login_status.py | 41 ++- .../cli/login/test_login_common.py | 43 +-- 12 files changed, 362 insertions(+), 419 deletions(-) delete mode 100644 bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py delete mode 100644 bfabric_scripts/src/bfabric_scripts/cli/login/list.py delete mode 100644 bfabric_scripts/src/bfabric_scripts/cli/login/logout.py create mode 100644 bfabric_scripts/src/bfabric_scripts/cli/login/manage.py delete mode 100644 bfabric_scripts/src/bfabric_scripts/cli/login/status.py diff --git a/bfabric_scripts/src/bfabric_scripts/cli/cli_auth.py b/bfabric_scripts/src/bfabric_scripts/cli/cli_auth.py index c3364e22..1d35a887 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/cli_auth.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/cli_auth.py @@ -1,13 +1,10 @@ import cyclopts -from bfabric_scripts.cli.login.default_config import cmd_auth_default -from bfabric_scripts.cli.login.list import cmd_auth_list -from bfabric_scripts.cli.login.logout import cmd_login_logout +from bfabric_scripts.cli.login.manage import cmd_auth_default, cmd_auth_list, cmd_login_logout, cmd_login_status from bfabric_scripts.cli.login.oauth_login import cmd_auth_login, cmd_login_device_code from bfabric_scripts.cli.login.pat import cmd_login_pat from bfabric_scripts.cli.login.register import cmd_login_register from bfabric_scripts.cli.login.register_webapp import cmd_login_register_webapp -from bfabric_scripts.cli.login.status import cmd_login_status cmd_auth = cyclopts.App(help="Authentication commands for B-Fabric.") _ = cmd_auth.command(cmd_login_pat, name="pat") diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py b/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py index e09d6c99..9b324966 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/_common.py @@ -1,19 +1,17 @@ -"""Shared helpers for the ``auth`` command group. +"""Shared login-parameter resolution for the ``auth`` login commands. -Parameter resolution (environment name, OAuth scope, set-default) plus the environment rendering -shared by ``auth default`` / ``auth list`` / ``auth status``. +An environment name and OAuth scope may be given on the command line, picked interactively, or -- +for the environment -- typed as a new value; whether the environment becomes the config default +is likewise resolved here. Used by ``auth pat`` / ``auth login`` / ``auth device-code``. """ from __future__ import annotations from pathlib import Path -from urllib.parse import urlsplit import yaml -from rich.console import Console -from rich.text import Text -from bfabric.config.config_file import ConfigFile, EnvironmentConfig +from bfabric.config.config_file import ConfigFile from bfabric_scripts.cli.interactive import confirm, is_interactive, select_choice, select_or_input, text_input from bfabric_scripts.cli.login._constants import SCOPE_PRESETS, SCOPE_PRESETS_BY_NAME @@ -91,84 +89,3 @@ def resolve_set_default(set_default: bool | None, config_env: str) -> bool | Non if not is_interactive(): return True return confirm(f"Set '{config_env}' as the default environment?", default=True) - - -def _normalize_scope(scope: str) -> str: - """Order-insensitive form of a scope string for comparing against the presets.""" - return " ".join(sorted(scope.split())) - - -def describe_scope(scope: object) -> str: - """Render a granted OAuth scope for display, annotated with its preset name if it matches. - - A preset match (order-insensitive) appends ``[]``; other non-empty scopes render raw; - missing / non-string input (a cache without a recorded scope) renders ``"(not recorded)"``. - """ - if not isinstance(scope, str) or not scope.strip(): - return "(not recorded)" - normalized = _normalize_scope(scope) - for preset in SCOPE_PRESETS: - if _normalize_scope(preset.scope) == normalized: - return f"{scope} [{preset.name}]" - return scope - - -def _format_duration(seconds: float) -> str: - """A coarse ``~1h05m`` / ``~7m`` / ``<1m`` label for a positive remaining duration.""" - minutes = int(seconds // 60) - if minutes >= 60: - hours, mins = divmod(minutes, 60) - return f"~{hours}h{mins:02d}m" - if minutes >= 1: - return f"~{minutes}m" - return "<1m" - - -def describe_token_cache(cached: dict[str, object] | None, *, now: float) -> str: - """Summarize a cached OAuth token's freshness: ``"missing"`` when absent, else ``"present"``, - extended with ``expired`` / ``expires in ~…`` when it carries a numeric ``expires_at``. - """ - if cached is None: - return "missing" - expires_at = cached.get("expires_at") - if not isinstance(expires_at, (int, float)): - return "present" - remaining = float(expires_at) - now - if remaining <= 0: - return "present, expired" - return f"present, expires in {_format_duration(remaining)}" - - -def environment_summary(env: EnvironmentConfig) -> str: - """A compact "host · auth-method" descriptor shown next to each environment name.""" - if env.auth_method in ("oauth", "pat"): - method = env.auth_method - elif env.auth is not None: - method = "password" - else: - method = "none" - base_url = str(env.config.base_url) - host = urlsplit(base_url).netloc or base_url - return f"{host} · {method}" - - -def print_environments(console: Console, environments: dict[str, EnvironmentConfig], default: str | None) -> None: - """List the configured environments with their host/auth summary, marking the default. - - Rows are built as ``Text`` (not console markup) so a name containing "[" stays literal. - """ - console.print("Configuration environments:") - width = max((len(name) for name in environments), default=0) - for name, env in environments.items(): - padded = name.ljust(width) - summary = environment_summary(env) - if name == default: - row = ( - Text("→ ", style="bold green") - .append(padded, style="bold green") - .append(f" {summary}", style="green") - .append(" (default)", style="green") - ) - else: - row = Text(" ").append(padded).append(f" {summary}", style="dim") - console.print(row) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py b/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py deleted file mode 100644 index fdb03473..00000000 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/default_config.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Command to show and set the default configuration environment.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Annotated - -import cyclopts -import yaml -from rich.console import Console - -from bfabric.config import DEFAULT_CONFIG_FILE -from bfabric.config.config_file import ConfigFile -from bfabric.config.config_writer import set_default_config -from bfabric_scripts.cli.interactive import is_interactive, select_choice -from bfabric_scripts.cli.login._common import environment_summary, print_environments - - -def cmd_auth_default( - config_env: Annotated[ - str | None, - cyclopts.Parameter(help="Environment to set as default (interactive picker if omitted)."), - ] = None, - *, - config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, -) -> None: - """Set the default configuration environment. - - With no *config_env*, opens an interactive picker in a terminal, or lists the environments - non-interactively. - """ - config_path = Path(config_file).expanduser() - if not config_path.is_file(): - print(f"Config file not found: {config_path}") - return - - config_file_obj = ConfigFile.model_validate(yaml.safe_load(config_path.read_text())) - names = list(config_file_obj.environments) - if not names: - print("No environments configured.") - return - - default = config_file_obj.general.default_config - width = max(len(name) for name in names) - if config_env is None and is_interactive(): - config_env = select_choice( - "Select the default environment", - names, - default=default if default in names else None, - describe=lambda name: f"{name.ljust(width)} {environment_summary(config_file_obj.environments[name])}", - search=True, - ) - if config_env is None: - # None: the user cancelled the picker, or there's no TTY to prompt on. - console = Console() - if is_interactive(): - console.print("No changes made.") - else: - print_environments(console, config_file_obj.environments, default) - console.print("Run in an interactive terminal or pass an environment name to set the default.") - return - - if config_env not in config_file_obj.environments: - print(f"Environment '{config_env}' not found. Available environments: {', '.join(names)}") - return - - set_default_config(config_path, config_env) - print(f"Default environment set to '{config_env}'.") diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/list.py b/bfabric_scripts/src/bfabric_scripts/cli/login/list.py deleted file mode 100644 index 2975f19f..00000000 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/list.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Command to list the configured environments.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Annotated - -import cyclopts -import yaml -from rich.console import Console - -from bfabric.config import DEFAULT_CONFIG_FILE -from bfabric.config.config_file import ConfigFile -from bfabric_scripts.cli.login._common import print_environments - - -def cmd_auth_list( - *, - config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, -) -> None: - """List the configured environments, marking the default and showing each host / auth method.""" - config_path = Path(config_file).expanduser() - if not config_path.is_file(): - print(f"Config file not found: {config_path}") - return - - config_file_obj = ConfigFile.model_validate(yaml.safe_load(config_path.read_text())) - if not config_file_obj.environments: - print("No environments configured.") - return - - print_environments(Console(), config_file_obj.environments, config_file_obj.general.default_config) diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py b/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py deleted file mode 100644 index 8b589be0..00000000 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/logout.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Logout command — removes an environment (config entry + any cached OAuth tokens).""" - -from __future__ import annotations - -import sys -from pathlib import Path -from typing import Annotated - -import cyclopts -import yaml - -from bfabric.config import DEFAULT_CONFIG_FILE -from bfabric.config.config_file import ConfigFile -from bfabric._oauth.token_cache import TokenCache, compute_token_cache_path -from bfabric.config.config_writer import remove_environment_from_config -from bfabric_scripts.cli.interactive import confirm, is_interactive, select_choice -from bfabric_scripts.cli.login._common import environment_summary -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID - - -def cmd_login_logout( - config_env: Annotated[ - str | None, - cyclopts.Parameter(help="Environment to remove (interactive picker if omitted)."), - ] = None, - *, - config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - no_confirm: Annotated[ - bool, cyclopts.Parameter(help="Skip the confirmation prompt (required to remove non-interactively).") - ] = False, -) -> None: - """Remove an environment: delete its config entry and clear any cached OAuth tokens. - - With no *config_env*, opens an interactive picker. A non-interactive run must name the - environment and pass ``--no-confirm`` (it cannot prompt for the destructive confirmation). - """ - config_path = Path(config_file).expanduser() - if not config_path.is_file(): - print(f"Config file not found: {config_path}") - return - - config_file_obj = ConfigFile.model_validate(yaml.safe_load(config_path.read_text())) - environments = config_file_obj.environments - names = list(environments) - if not names: - print("No environments configured.") - return - - if config_env is None: - if not is_interactive(): - print("Specify --config-env to choose an environment to remove non-interactively.", file=sys.stderr) - return - width = max(len(name) for name in names) - default = config_file_obj.general.default_config - config_env = select_choice( - "Select the environment to remove", - names, - default=default if default in names else None, - describe=lambda name: f"{name.ljust(width)} {environment_summary(environments[name])}", - search=True, - ) - if config_env is None: - print("No changes made.") - return - - if config_env not in environments: - print(f"Environment '{config_env}' not found. Available environments: {', '.join(names)}") - return - - env = environments[config_env] - # Removing the current default clears it (a dangling default makes the config unloadable). Only - # worth flagging when other environments remain to default to. - leaves_no_default = config_env == config_file_obj.general.default_config and len(names) > 1 - - if not no_confirm: - if not is_interactive(): - print( - f"Refusing to remove '{config_env}' without confirmation; pass --no-confirm to proceed.", - file=sys.stderr, - ) - return - prompt = ( - f"Remove environment '{config_env}' ({environment_summary(env)})? " - "This deletes its config entry and any cached OAuth tokens." - ) - if leaves_no_default: - prompt += " It is the current default; afterwards no default will be set." - if not confirm(prompt): - print("No changes made.") - return - - # Remove the config entry first: if that write fails, the cached token is left intact so the - # environment stays usable, rather than half-removed. - remove_environment_from_config(config_path, config_env) - if env.auth_method == "oauth": - client_id = env.client_id or DEFAULT_CLIENT_ID - cache_path = compute_token_cache_path(env.config.base_url.rstrip("/"), client_id, config_env).expanduser() - TokenCache(cache_path).clear() - - print(f"Removed environment '{config_env}'.") - if leaves_no_default: - print("It was the default environment; set a new default with 'bfabric-cli auth default '.") diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/manage.py b/bfabric_scripts/src/bfabric_scripts/cli/login/manage.py new file mode 100644 index 00000000..74227d2c --- /dev/null +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/manage.py @@ -0,0 +1,297 @@ +"""Auth commands that inspect or manage existing config environments: list, default, status, logout. + +All four load the config file and act on a named environment, so the config-load, environment +picker, and host/auth/scope rendering helpers live here alongside them. +""" + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path +from typing import Annotated +from urllib.parse import urlsplit + +import cyclopts +import yaml +from rich.console import Console +from rich.text import Text + +from bfabric._oauth.token_cache import TokenCache, compute_token_cache_path +from bfabric.config import DEFAULT_CONFIG_FILE +from bfabric.config.config_file import ConfigFile, EnvironmentConfig +from bfabric.config.config_writer import remove_environment_from_config, set_default_config +from bfabric_scripts.cli.interactive import confirm, is_interactive, select_choice +from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID, SCOPE_PRESETS + + +def _load_config(config_file: Path) -> ConfigFile | None: + """Load and validate the config file, or print a "not found" notice and return ``None``.""" + config_path = Path(config_file).expanduser() + if not config_path.is_file(): + print(f"Config file not found: {config_path}") + return None + return ConfigFile.model_validate(yaml.safe_load(config_path.read_text())) + + +def environment_summary(env: EnvironmentConfig) -> str: + """A compact "host · auth-method" descriptor shown next to each environment name.""" + if env.auth_method in ("oauth", "pat"): + method = env.auth_method + elif env.auth is not None: + method = "password" + else: + method = "none" + base_url = str(env.config.base_url) + host = urlsplit(base_url).netloc or base_url + return f"{host} · {method}" + + +def print_environments(console: Console, environments: dict[str, EnvironmentConfig], default: str | None) -> None: + """List the configured environments with their host/auth summary, marking the default. + + Rows are built as ``Text`` (not console markup) so a name containing "[" stays literal. + """ + console.print("Configuration environments:") + width = max((len(name) for name in environments), default=0) + for name, env in environments.items(): + padded = name.ljust(width) + summary = environment_summary(env) + if name == default: + row = ( + Text("→ ", style="bold green") + .append(padded, style="bold green") + .append(f" {summary}", style="green") + .append(" (default)", style="green") + ) + else: + row = Text(" ").append(padded).append(f" {summary}", style="dim") + console.print(row) + + +def _select_environment(message: str, config: ConfigFile) -> str | None: + """Interactive picker over the configured environments, each labelled with its host/auth summary.""" + names = list(config.environments) + width = max(len(name) for name in names) + default = config.general.default_config + return select_choice( + message, + names, + default=default if default in names else None, + describe=lambda name: f"{name.ljust(width)} {environment_summary(config.environments[name])}", + search=True, + ) + + +def _normalize_scope(scope: str) -> str: + """Order-insensitive form of a scope string for comparing against the presets.""" + return " ".join(sorted(scope.split())) + + +def describe_scope(scope: object) -> str: + """Render a granted OAuth scope for display, annotated with its preset name if it matches. + + A preset match (order-insensitive) appends ``[]``; other non-empty scopes render raw; + missing / non-string input (a cache without a recorded scope) renders ``"(not recorded)"``. + """ + if not isinstance(scope, str) or not scope.strip(): + return "(not recorded)" + normalized = _normalize_scope(scope) + for preset in SCOPE_PRESETS: + if _normalize_scope(preset.scope) == normalized: + return f"{scope} [{preset.name}]" + return scope + + +def _format_duration(seconds: float) -> str: + """A coarse ``~1h05m`` / ``~7m`` / ``<1m`` label for a positive remaining duration.""" + minutes = int(seconds // 60) + if minutes >= 60: + hours, mins = divmod(minutes, 60) + return f"~{hours}h{mins:02d}m" + if minutes >= 1: + return f"~{minutes}m" + return "<1m" + + +def describe_token_cache(cached: dict[str, object] | None, *, now: float) -> str: + """Summarize a cached OAuth token's freshness: ``"missing"`` when absent, else ``"present"``, + extended with ``expired`` / ``expires in ~…`` when it carries a numeric ``expires_at``. + """ + if cached is None: + return "missing" + expires_at = cached.get("expires_at") + if not isinstance(expires_at, (int, float)): + return "present" + remaining = float(expires_at) - now + if remaining <= 0: + return "present, expired" + return f"present, expires in {_format_duration(remaining)}" + + +def cmd_auth_list( + *, + config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, +) -> None: + """List the configured environments, marking the default and showing each host / auth method.""" + config = _load_config(config_file) + if config is None: + return + if not config.environments: + print("No environments configured.") + return + print_environments(Console(), config.environments, config.general.default_config) + + +def cmd_auth_default( + config_env: Annotated[ + str | None, + cyclopts.Parameter(help="Environment to set as default (interactive picker if omitted)."), + ] = None, + *, + config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, +) -> None: + """Set the default configuration environment. + + With no *config_env*, opens an interactive picker in a terminal, or lists the environments + non-interactively. + """ + config = _load_config(config_file) + if config is None: + return + names = list(config.environments) + if not names: + print("No environments configured.") + return + + if config_env is None and is_interactive(): + config_env = _select_environment("Select the default environment", config) + if config_env is None: + # None: the user cancelled the picker, or there's no TTY to prompt on. + console = Console() + if is_interactive(): + console.print("No changes made.") + else: + print_environments(console, config.environments, config.general.default_config) + console.print("Run in an interactive terminal or pass an environment name to set the default.") + return + + if config_env not in config.environments: + print(f"Environment '{config_env}' not found. Available environments: {', '.join(names)}") + return + + set_default_config(config_file, config_env) + print(f"Default environment set to '{config_env}'.") + + +def cmd_login_status( + *, + config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, + config_env: Annotated[str | None, cyclopts.Parameter(help="Environment name (default: auto-detect).")] = None, +) -> None: + """Show current authentication status.""" + config = _load_config(config_file) + if config is None: + return + resolved_env = config_env or os.environ.get("BFABRICPY_CONFIG_ENV") or config.general.default_config + if resolved_env is None: + print("No environment specified and no default configured.") + return + if resolved_env not in config.environments: + print(f"Environment '{resolved_env}' not found in config.") + return + + env = config.environments[resolved_env] + print(f"Environment: {resolved_env}") + print(f"Base URL: {env.config.base_url}") + + if env.auth_method == "oauth": + client_id = env.client_id or DEFAULT_CLIENT_ID + print("Auth method: oauth") + print(f"Client ID: {client_id}") + cache_path = compute_token_cache_path(env.config.base_url.rstrip("/"), client_id, resolved_env).expanduser() + cached = TokenCache(cache_path).load() + print(f"Token cache: {cache_path} ({describe_token_cache(cached, now=time.time())})") + print(f"Scope: {describe_scope(cached.get('scope') if cached else None)}") + elif env.auth_method == "pat": + print("Auth method: pat") + print("Token: stored in config file") + elif env.auth is not None: + print("Auth method: password") + print(f"Login: {env.auth.login}") + else: + print("Auth method: none") + + +def cmd_login_logout( + config_env: Annotated[ + str | None, + cyclopts.Parameter(help="Environment to remove (interactive picker if omitted)."), + ] = None, + *, + config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, + no_confirm: Annotated[ + bool, cyclopts.Parameter(help="Skip the confirmation prompt (required to remove non-interactively).") + ] = False, +) -> None: + """Remove an environment: delete its config entry and clear any cached OAuth tokens. + + With no *config_env*, opens an interactive picker. A non-interactive run must name the + environment and pass ``--no-confirm`` (it cannot prompt for the destructive confirmation). + """ + config = _load_config(config_file) + if config is None: + return + environments = config.environments + names = list(environments) + if not names: + print("No environments configured.") + return + + if config_env is None: + if not is_interactive(): + print("Specify --config-env to choose an environment to remove non-interactively.", file=sys.stderr) + return + config_env = _select_environment("Select the environment to remove", config) + if config_env is None: + print("No changes made.") + return + + if config_env not in environments: + print(f"Environment '{config_env}' not found. Available environments: {', '.join(names)}") + return + + env = environments[config_env] + # Removing the current default clears it (a dangling default makes the config unloadable). Only + # worth flagging when other environments remain to default to. + leaves_no_default = config_env == config.general.default_config and len(names) > 1 + + if not no_confirm: + if not is_interactive(): + print( + f"Refusing to remove '{config_env}' without confirmation; pass --no-confirm to proceed.", + file=sys.stderr, + ) + return + prompt = ( + f"Remove environment '{config_env}' ({environment_summary(env)})? " + "This deletes its config entry and any cached OAuth tokens." + ) + if leaves_no_default: + prompt += " It is the current default; afterwards no default will be set." + if not confirm(prompt): + print("No changes made.") + return + + # Remove the config entry first: if that write fails, the cached token is left intact so the + # environment stays usable, rather than half-removed. + remove_environment_from_config(config_file, config_env) + if env.auth_method == "oauth": + client_id = env.client_id or DEFAULT_CLIENT_ID + cache_path = compute_token_cache_path(env.config.base_url.rstrip("/"), client_id, config_env).expanduser() + TokenCache(cache_path).clear() + + print(f"Removed environment '{config_env}'.") + if leaves_no_default: + print("It was the default environment; set a new default with 'bfabric-cli auth default '.") diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/status.py b/bfabric_scripts/src/bfabric_scripts/cli/login/status.py deleted file mode 100644 index d186fa15..00000000 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/status.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Login status command.""" - -from __future__ import annotations - -import os -import time -from pathlib import Path -from typing import Annotated - -import cyclopts -import yaml - -from bfabric.config import DEFAULT_CONFIG_FILE -from bfabric.config.config_file import ConfigFile -from bfabric._oauth.token_cache import TokenCache, compute_token_cache_path -from bfabric_scripts.cli.login._common import describe_scope, describe_token_cache -from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID - - -def cmd_login_status( - *, - config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, - config_env: Annotated[str | None, cyclopts.Parameter(help="Environment name (default: auto-detect).")] = None, -) -> None: - """Show current authentication status.""" - config_path = Path(config_file).expanduser() - if not config_path.is_file(): - print(f"Config file not found: {config_path}") - return - - config_file_obj = ConfigFile.model_validate(yaml.safe_load(config_path.read_text())) - resolved_env = config_env or os.environ.get("BFABRICPY_CONFIG_ENV") or config_file_obj.general.default_config - if resolved_env is None: - print("No environment specified and no default configured.") - return - - if resolved_env not in config_file_obj.environments: - print(f"Environment '{resolved_env}' not found in config.") - return - - env = config_file_obj.environments[resolved_env] - print(f"Environment: {resolved_env}") - print(f"Base URL: {env.config.base_url}") - - if env.auth_method == "oauth": - client_id = env.client_id or DEFAULT_CLIENT_ID - print("Auth method: oauth") - print(f"Client ID: {client_id}") - cache_path = compute_token_cache_path(env.config.base_url.rstrip("/"), client_id, resolved_env).expanduser() - cached = TokenCache(cache_path).load() - print(f"Token cache: {cache_path} ({describe_token_cache(cached, now=time.time())})") - print(f"Scope: {describe_scope(cached.get('scope') if cached else None)}") - elif env.auth_method == "pat": - print("Auth method: pat") - print("Token: stored in config file") - elif env.auth is not None: - print("Auth method: password") - print(f"Login: {env.auth.login}") - else: - print("Auth method: none") diff --git a/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py b/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py index eb0b713d..88196873 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py @@ -2,7 +2,7 @@ import yaml -from bfabric_scripts.cli.login.default_config import cmd_auth_default +from bfabric_scripts.cli.login.manage import cmd_auth_default def _write_config(config_file, default="PROD"): @@ -46,8 +46,8 @@ def test_selects_default_via_picker(self, tmp_path, mocker): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") # The picker runs only when no value is passed; it returns the chosen environment. - mocker.patch("bfabric_scripts.cli.login.default_config.is_interactive", return_value=True) - picker = mocker.patch("bfabric_scripts.cli.login.default_config.select_choice", return_value="TEST") + mocker.patch("bfabric_scripts.cli.login.manage.is_interactive", return_value=True) + picker = mocker.patch("bfabric_scripts.cli.login.manage.select_choice", return_value="TEST") cmd_auth_default(config_file=config_file) data = yaml.safe_load(config_file.read_text()) assert data["GENERAL"]["default_config"] == "TEST" @@ -58,8 +58,8 @@ def test_cancelled_picker_leaves_file_unchanged(self, tmp_path, mocker, capsys): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") before = config_file.read_text() - mocker.patch("bfabric_scripts.cli.login.default_config.select_choice", return_value=None) - mocker.patch("bfabric_scripts.cli.login.default_config.is_interactive", return_value=True) + mocker.patch("bfabric_scripts.cli.login.manage.select_choice", return_value=None) + mocker.patch("bfabric_scripts.cli.login.manage.is_interactive", return_value=True) cmd_auth_default(config_file=config_file) assert "No changes made" in capsys.readouterr().out assert config_file.read_text() == before @@ -70,7 +70,7 @@ def test_lists_environments_when_no_tty_and_no_value(self, tmp_path, mocker, cap config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") before = config_file.read_text() - mocker.patch("bfabric_scripts.cli.login.default_config.is_interactive", return_value=False) + mocker.patch("bfabric_scripts.cli.login.manage.is_interactive", return_value=False) cmd_auth_default(config_file=config_file) output = capsys.readouterr().out assert "PROD" in output diff --git a/tests/bfabric_scripts/cli/login/test_cmd_auth_list.py b/tests/bfabric_scripts/cli/login/test_cmd_auth_list.py index fc10be09..ef4c340c 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_auth_list.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_auth_list.py @@ -2,7 +2,7 @@ import yaml -from bfabric_scripts.cli.login.list import cmd_auth_list +from bfabric_scripts.cli.login.manage import cmd_auth_list class TestCmdAuthList: diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py b/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py index 5c2af7cc..a7655b09 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_logout.py @@ -4,7 +4,7 @@ import yaml -from bfabric_scripts.cli.login.logout import cmd_login_logout +from bfabric_scripts.cli.login.manage import cmd_login_logout def _write_config(config_file, *, default="PROD", extra_prod=None): @@ -32,7 +32,7 @@ def test_removes_oauth_env_and_clears_cache(self, tmp_path, capsys, mocker): _write_config(config_file, default="PROD") cache_path = tmp_path / "tok.json" cache_path.write_text(json.dumps({"access_token": "tok"})) - mocker.patch("bfabric_scripts.cli.login.logout.compute_token_cache_path", return_value=cache_path) + mocker.patch("bfabric_scripts.cli.login.manage.compute_token_cache_path", return_value=cache_path) cmd_login_logout("PROD", config_file=config_file, no_confirm=True) @@ -47,7 +47,7 @@ def test_removes_oauth_env_and_clears_cache(self, tmp_path, capsys, mocker): def test_removing_default_env_points_to_auth_default(self, tmp_path, capsys, mocker): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") - mocker.patch("bfabric_scripts.cli.login.logout.compute_token_cache_path", return_value=tmp_path / "tok.json") + mocker.patch("bfabric_scripts.cli.login.manage.compute_token_cache_path", return_value=tmp_path / "tok.json") cmd_login_logout("PROD", config_file=config_file, no_confirm=True) @@ -60,7 +60,7 @@ def test_removing_default_env_points_to_auth_default(self, tmp_path, capsys, moc def test_removes_non_oauth_env_without_touching_cache(self, tmp_path, capsys, mocker): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") - clear = mocker.patch("bfabric_scripts.cli.login.logout.TokenCache") + clear = mocker.patch("bfabric_scripts.cli.login.manage.TokenCache") cmd_login_logout("TEST", config_file=config_file, no_confirm=True) @@ -71,8 +71,8 @@ def test_removes_non_oauth_env_without_touching_cache(self, tmp_path, capsys, mo def test_confirmation_declined_keeps_env(self, tmp_path, capsys, mocker): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") - mocker.patch("bfabric_scripts.cli.login.logout.is_interactive", return_value=True) - mocker.patch("bfabric_scripts.cli.login.logout.confirm", return_value=False) + mocker.patch("bfabric_scripts.cli.login.manage.is_interactive", return_value=True) + mocker.patch("bfabric_scripts.cli.login.manage.confirm", return_value=False) cmd_login_logout("PROD", config_file=config_file) @@ -83,9 +83,9 @@ def test_confirmation_declined_keeps_env(self, tmp_path, capsys, mocker): def test_interactive_picker_selects_env(self, tmp_path, mocker): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") - mocker.patch("bfabric_scripts.cli.login.logout.is_interactive", return_value=True) - pick = mocker.patch("bfabric_scripts.cli.login.logout.select_choice", return_value="TEST") - mocker.patch("bfabric_scripts.cli.login.logout.confirm", return_value=True) + mocker.patch("bfabric_scripts.cli.login.manage.is_interactive", return_value=True) + pick = mocker.patch("bfabric_scripts.cli.login.manage.select_choice", return_value="TEST") + mocker.patch("bfabric_scripts.cli.login.manage.confirm", return_value=True) cmd_login_logout(config_file=config_file) @@ -97,8 +97,8 @@ def test_interactive_picker_selects_env(self, tmp_path, mocker): def test_interactive_picker_cancel_keeps_all(self, tmp_path, capsys, mocker): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") - mocker.patch("bfabric_scripts.cli.login.logout.is_interactive", return_value=True) - mocker.patch("bfabric_scripts.cli.login.logout.select_choice", return_value=None) + mocker.patch("bfabric_scripts.cli.login.manage.is_interactive", return_value=True) + mocker.patch("bfabric_scripts.cli.login.manage.select_choice", return_value=None) cmd_login_logout(config_file=config_file) @@ -107,7 +107,7 @@ def test_interactive_picker_cancel_keeps_all(self, tmp_path, capsys, mocker): def test_non_interactive_without_env_aborts(self, tmp_path, capsys, mocker): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") - mocker.patch("bfabric_scripts.cli.login.logout.is_interactive", return_value=False) + mocker.patch("bfabric_scripts.cli.login.manage.is_interactive", return_value=False) cmd_login_logout(config_file=config_file) @@ -118,7 +118,7 @@ def test_non_interactive_without_env_aborts(self, tmp_path, capsys, mocker): def test_non_interactive_without_no_confirm_refuses(self, tmp_path, capsys, mocker): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") - mocker.patch("bfabric_scripts.cli.login.logout.is_interactive", return_value=False) + mocker.patch("bfabric_scripts.cli.login.manage.is_interactive", return_value=False) cmd_login_logout("PROD", config_file=config_file) diff --git a/tests/bfabric_scripts/cli/login/test_cmd_login_status.py b/tests/bfabric_scripts/cli/login/test_cmd_login_status.py index 36121743..3a179d26 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_login_status.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_login_status.py @@ -6,7 +6,7 @@ import yaml from bfabric_scripts.cli.login._constants import SCOPE_PRESETS_BY_NAME -from bfabric_scripts.cli.login.status import cmd_login_status +from bfabric_scripts.cli.login.manage import cmd_login_status, describe_scope, describe_token_cache class TestCmdLoginStatus: @@ -83,7 +83,7 @@ def test_oauth_reports_missing_token_and_unrecorded_scope(self, tmp_path, capsys ) ) # No cache file on disk -> missing token, no scope to report. - mocker.patch("bfabric_scripts.cli.login.status.compute_token_cache_path", return_value=tmp_path / "absent.json") + mocker.patch("bfabric_scripts.cli.login.manage.compute_token_cache_path", return_value=tmp_path / "absent.json") cmd_login_status(config_file=config_file) output = capsys.readouterr().out assert "missing" in output @@ -113,7 +113,7 @@ def test_oauth_shows_matched_scope_and_expiry_when_cached(self, tmp_path, capsys } ) ) - mocker.patch("bfabric_scripts.cli.login.status.compute_token_cache_path", return_value=cache_path) + mocker.patch("bfabric_scripts.cli.login.manage.compute_token_cache_path", return_value=cache_path) cmd_login_status(config_file=config_file) output = capsys.readouterr().out # The granted scope matches the upload preset and the token is still valid. @@ -125,3 +125,38 @@ def test_missing_config_file(self, tmp_path, capsys): cmd_login_status(config_file=config_file) output = capsys.readouterr().out assert "not found" in output + + +class TestDescribeScope: + def test_matched_preset_is_annotated(self): + # A granted scope equal to a preset (order-insensitive) is annotated with the preset name. + scope = "tus api:write" # reordered to prove match is order-insensitive + described = describe_scope(scope) + assert "upload" in described + assert "tus" in described + + def test_unmatched_scope_shown_raw(self): + assert describe_scope("api:read custom:thing") == "api:read custom:thing" + + def test_absent_scope_is_not_recorded(self): + assert describe_scope(None) == "(not recorded)" + assert describe_scope("") == "(not recorded)" + # A non-string (unexpected cache shape) must not blow up. + assert describe_scope(123) == "(not recorded)" + + +class TestDescribeTokenCache: + def test_missing_cache(self): + assert describe_token_cache(None, now=1000.0) == "missing" + + def test_present_without_expiry(self): + assert describe_token_cache({"access_token": "x"}, now=1000.0) == "present" + + def test_expired(self): + assert "expired" in describe_token_cache({"access_token": "x", "expires_at": 900}, now=1000.0) + + def test_valid_reports_remaining(self): + # 2h30m from now. + described = describe_token_cache({"access_token": "x", "expires_at": 1000 + 9000}, now=1000.0) + assert "present" in described + assert "2h" in described diff --git a/tests/bfabric_scripts/cli/login/test_login_common.py b/tests/bfabric_scripts/cli/login/test_login_common.py index fbc10caa..8ba87823 100644 --- a/tests/bfabric_scripts/cli/login/test_login_common.py +++ b/tests/bfabric_scripts/cli/login/test_login_common.py @@ -2,13 +2,7 @@ import yaml -from bfabric_scripts.cli.login._common import ( - describe_scope, - describe_token_cache, - resolve_config_env, - resolve_scope, - resolve_set_default, -) +from bfabric_scripts.cli.login._common import resolve_config_env, resolve_scope, resolve_set_default def _write_config(config_file, default="PROD"): @@ -99,41 +93,6 @@ def test_interactive_cancel_returns_none(self, mocker): assert resolve_scope(None) is None -class TestDescribeScope: - def test_matched_preset_is_annotated(self): - # A granted scope equal to a preset (order-insensitive) is annotated with the preset name. - scope = "tus api:write" # reordered to prove match is order-insensitive - described = describe_scope(scope) - assert "upload" in described - assert "tus" in described - - def test_unmatched_scope_shown_raw(self): - assert describe_scope("api:read custom:thing") == "api:read custom:thing" - - def test_absent_scope_is_not_recorded(self): - assert describe_scope(None) == "(not recorded)" - assert describe_scope("") == "(not recorded)" - # A non-string (unexpected cache shape) must not blow up. - assert describe_scope(123) == "(not recorded)" - - -class TestDescribeTokenCache: - def test_missing_cache(self): - assert describe_token_cache(None, now=1000.0) == "missing" - - def test_present_without_expiry(self): - assert describe_token_cache({"access_token": "x"}, now=1000.0) == "present" - - def test_expired(self): - assert "expired" in describe_token_cache({"access_token": "x", "expires_at": 900}, now=1000.0) - - def test_valid_reports_remaining(self): - # 2h30m from now. - described = describe_token_cache({"access_token": "x", "expires_at": 1000 + 9000}, now=1000.0) - assert "present" in described - assert "2h" in described - - class TestResolveSetDefault: def test_explicit_true_honored_without_prompt(self, mocker): confirm = mocker.patch("bfabric_scripts.cli.login._common.confirm") From 34602cb2029a8d9dfbacb508884fca47aa1e04f0 Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Fri, 17 Jul 2026 11:36:38 +0200 Subject: [PATCH 27/29] Further consolidation --- bfabric_scripts/pyproject.toml | 2 +- .../src/bfabric_scripts/cli/__main__.py | 15 ++- .../src/bfabric_scripts/cli/login/manage.py | 124 +++++++----------- .../bfabric_scripts/cli/login/oauth_login.py | 19 +-- .../cli/login/test_cmd_auth_default.py | 15 +-- 5 files changed, 73 insertions(+), 102 deletions(-) diff --git a/bfabric_scripts/pyproject.toml b/bfabric_scripts/pyproject.toml index 9d0eae1f..de339d6e 100644 --- a/bfabric_scripts/pyproject.toml +++ b/bfabric_scripts/pyproject.toml @@ -36,7 +36,7 @@ excel = [ ] [project.scripts] -"bfabric-cli" = "bfabric_scripts.cli.__main__:app" +"bfabric-cli" = "bfabric_scripts.cli.__main__:main" # TODO scripts for feeder "bfabric_list_not_existing_storage_directories.py" = "bfabric_scripts.bfabric_list_not_existing_storage_directories:app" diff --git a/bfabric_scripts/src/bfabric_scripts/cli/__main__.py b/bfabric_scripts/src/bfabric_scripts/cli/__main__.py index 511107af..8ef44dbd 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/__main__.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/__main__.py @@ -2,6 +2,7 @@ import cyclopts +from bfabric.utils.cli_integration import setup_script_logging from bfabric_scripts.cli.cli_api import cmd_api from bfabric_scripts.cli.cli_dataset import cmd_dataset from bfabric_scripts.cli.cli_executable import cmd_executable @@ -23,5 +24,17 @@ # TODO delete after transitory release _ = app.command(_app_external_job, name="external-job") -if __name__ == "__main__": + +def main() -> None: + """CLI entry point: configure logging once, then dispatch. + + Commands using ``@use_client`` set logging up themselves, but those that don't (e.g. ``auth``) + would otherwise run under loguru's default DEBUG handler. Setting up here covers every command; + the per-command call is idempotent. + """ + setup_script_logging() app() + + +if __name__ == "__main__": + main() diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/manage.py b/bfabric_scripts/src/bfabric_scripts/cli/login/manage.py index 74227d2c..fda93eee 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/manage.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/manage.py @@ -1,7 +1,6 @@ """Auth commands that inspect or manage existing config environments: list, default, status, logout. -All four load the config file and act on a named environment, so the config-load, environment -picker, and host/auth/scope rendering helpers live here alongside them. +Their shared config-load, environment picker, and host/auth/scope rendering helpers live here too. """ from __future__ import annotations @@ -15,8 +14,6 @@ import cyclopts import yaml -from rich.console import Console -from rich.text import Text from bfabric._oauth.token_cache import TokenCache, compute_token_cache_path from bfabric.config import DEFAULT_CONFIG_FILE @@ -35,39 +32,44 @@ def _load_config(config_file: Path) -> ConfigFile | None: return ConfigFile.model_validate(yaml.safe_load(config_path.read_text())) +def _require_environments(config_file: Path) -> ConfigFile | None: + """Like :func:`_load_config`, but also prints a notice and returns ``None`` if no environments exist.""" + config = _load_config(config_file) + if config is None: + return None + if not config.environments: + print("No environments configured.") + return None + return config + + +def _auth_method(env: EnvironmentConfig) -> str: + """Effective auth method; falls back to ``auth``'s presence for legacy envs without ``auth_method``.""" + if env.auth_method in ("oauth", "pat"): + return env.auth_method + return "password" if env.auth is not None else "none" + + +def _oauth_cache_path(env: EnvironmentConfig, env_name: str) -> Path: + """Disk path of *env_name*'s cached OAuth token (keyed by base URL + client ID).""" + client_id = env.client_id or DEFAULT_CLIENT_ID + return compute_token_cache_path(env.config.base_url.rstrip("/"), client_id, env_name).expanduser() + + def environment_summary(env: EnvironmentConfig) -> str: """A compact "host · auth-method" descriptor shown next to each environment name.""" - if env.auth_method in ("oauth", "pat"): - method = env.auth_method - elif env.auth is not None: - method = "password" - else: - method = "none" base_url = str(env.config.base_url) host = urlsplit(base_url).netloc or base_url - return f"{host} · {method}" + return f"{host} · {_auth_method(env)}" -def print_environments(console: Console, environments: dict[str, EnvironmentConfig], default: str | None) -> None: - """List the configured environments with their host/auth summary, marking the default. - - Rows are built as ``Text`` (not console markup) so a name containing "[" stays literal. - """ - console.print("Configuration environments:") +def print_environments(environments: dict[str, EnvironmentConfig], default: str | None) -> None: + """List the configured environments with their host/auth summary, marking the default.""" + print("Configuration environments:") width = max((len(name) for name in environments), default=0) for name, env in environments.items(): - padded = name.ljust(width) - summary = environment_summary(env) - if name == default: - row = ( - Text("→ ", style="bold green") - .append(padded, style="bold green") - .append(f" {summary}", style="green") - .append(" (default)", style="green") - ) - else: - row = Text(" ").append(padded).append(f" {summary}", style="dim") - console.print(row) + marker, tag = ("→", " (default)") if name == default else (" ", "") + print(f"{marker} {name.ljust(width)} {environment_summary(env)}{tag}") def _select_environment(message: str, config: ConfigFile) -> str | None: @@ -90,11 +92,7 @@ def _normalize_scope(scope: str) -> str: def describe_scope(scope: object) -> str: - """Render a granted OAuth scope for display, annotated with its preset name if it matches. - - A preset match (order-insensitive) appends ``[]``; other non-empty scopes render raw; - missing / non-string input (a cache without a recorded scope) renders ``"(not recorded)"``. - """ + """Render a granted scope, appending ``[]`` on match; ``"(not recorded)"`` if missing/non-string.""" if not isinstance(scope, str) or not scope.strip(): return "(not recorded)" normalized = _normalize_scope(scope) @@ -104,21 +102,8 @@ def describe_scope(scope: object) -> str: return scope -def _format_duration(seconds: float) -> str: - """A coarse ``~1h05m`` / ``~7m`` / ``<1m`` label for a positive remaining duration.""" - minutes = int(seconds // 60) - if minutes >= 60: - hours, mins = divmod(minutes, 60) - return f"~{hours}h{mins:02d}m" - if minutes >= 1: - return f"~{minutes}m" - return "<1m" - - def describe_token_cache(cached: dict[str, object] | None, *, now: float) -> str: - """Summarize a cached OAuth token's freshness: ``"missing"`` when absent, else ``"present"``, - extended with ``expired`` / ``expires in ~…`` when it carries a numeric ``expires_at``. - """ + """Summarize a cached token's freshness: ``missing`` / ``present`` (+ expiry when ``expires_at`` is set).""" if cached is None: return "missing" expires_at = cached.get("expires_at") @@ -127,7 +112,9 @@ def describe_token_cache(cached: dict[str, object] | None, *, now: float) -> str remaining = float(expires_at) - now if remaining <= 0: return "present, expired" - return f"present, expires in {_format_duration(remaining)}" + minutes = int(remaining // 60) + label = f"~{minutes // 60}h" if minutes >= 60 else f"~{minutes}m" + return f"present, expires in {label}" def cmd_auth_list( @@ -135,13 +122,10 @@ def cmd_auth_list( config_file: Annotated[Path, cyclopts.Parameter(help="Path to the config file.")] = DEFAULT_CONFIG_FILE, ) -> None: """List the configured environments, marking the default and showing each host / auth method.""" - config = _load_config(config_file) + config = _require_environments(config_file) if config is None: return - if not config.environments: - print("No environments configured.") - return - print_environments(Console(), config.environments, config.general.default_config) + print_environments(config.environments, config.general.default_config) def cmd_auth_default( @@ -157,24 +141,19 @@ def cmd_auth_default( With no *config_env*, opens an interactive picker in a terminal, or lists the environments non-interactively. """ - config = _load_config(config_file) + config = _require_environments(config_file) if config is None: return names = list(config.environments) - if not names: - print("No environments configured.") - return if config_env is None and is_interactive(): config_env = _select_environment("Select the default environment", config) if config_env is None: - # None: the user cancelled the picker, or there's no TTY to prompt on. - console = Console() + # Cancelled picker, or no TTY to prompt on. if is_interactive(): - console.print("No changes made.") + print("No changes made.") else: - print_environments(console, config.environments, config.general.default_config) - console.print("Run in an interactive terminal or pass an environment name to set the default.") + print("No environment specified. Pass an environment name or run in an interactive terminal.") return if config_env not in config.environments: @@ -205,23 +184,19 @@ def cmd_login_status( env = config.environments[resolved_env] print(f"Environment: {resolved_env}") print(f"Base URL: {env.config.base_url}") + print(f"Auth method: {_auth_method(env)}") if env.auth_method == "oauth": client_id = env.client_id or DEFAULT_CLIENT_ID - print("Auth method: oauth") - print(f"Client ID: {client_id}") - cache_path = compute_token_cache_path(env.config.base_url.rstrip("/"), client_id, resolved_env).expanduser() + cache_path = _oauth_cache_path(env, resolved_env) cached = TokenCache(cache_path).load() + print(f"Client ID: {client_id}") print(f"Token cache: {cache_path} ({describe_token_cache(cached, now=time.time())})") print(f"Scope: {describe_scope(cached.get('scope') if cached else None)}") elif env.auth_method == "pat": - print("Auth method: pat") print("Token: stored in config file") elif env.auth is not None: - print("Auth method: password") print(f"Login: {env.auth.login}") - else: - print("Auth method: none") def cmd_login_logout( @@ -240,14 +215,11 @@ def cmd_login_logout( With no *config_env*, opens an interactive picker. A non-interactive run must name the environment and pass ``--no-confirm`` (it cannot prompt for the destructive confirmation). """ - config = _load_config(config_file) + config = _require_environments(config_file) if config is None: return environments = config.environments names = list(environments) - if not names: - print("No environments configured.") - return if config_env is None: if not is_interactive(): @@ -288,9 +260,7 @@ def cmd_login_logout( # environment stays usable, rather than half-removed. remove_environment_from_config(config_file, config_env) if env.auth_method == "oauth": - client_id = env.client_id or DEFAULT_CLIENT_ID - cache_path = compute_token_cache_path(env.config.base_url.rstrip("/"), client_id, config_env).expanduser() - TokenCache(cache_path).clear() + TokenCache(_oauth_cache_path(env, config_env)).clear() print(f"Removed environment '{config_env}'.") if leaves_no_default: diff --git a/bfabric_scripts/src/bfabric_scripts/cli/login/oauth_login.py b/bfabric_scripts/src/bfabric_scripts/cli/login/oauth_login.py index 5d88864e..d3b83ba1 100644 --- a/bfabric_scripts/src/bfabric_scripts/cli/login/oauth_login.py +++ b/bfabric_scripts/src/bfabric_scripts/cli/login/oauth_login.py @@ -1,8 +1,7 @@ """Interactive OAuth login commands: browser (PKCE) and device-code flows. -Both resolve the same env/scope/set-default parameters and persist the resulting token -identically; they differ only in how the token is obtained (``_resolve_params`` / ``_persist`` -hold the shared parts). +They share param resolution and token persistence (``_resolve_params`` / ``_persist``); only the +token-acquisition step differs. """ from __future__ import annotations @@ -22,9 +21,7 @@ from bfabric_scripts.cli.login._common import resolve_config_env, resolve_scope, resolve_set_default from bfabric_scripts.cli.login._constants import DEFAULT_CLIENT_ID -_SCOPE_HELP = ( - "OAuth scope preset (read-only | read-write | upload) or a raw scope string " "(interactive picker if omitted)." -) +_SCOPE_HELP = "Scope preset (read-only|read-write|upload) or a raw scope string (interactive picker if omitted)." _CONFIG_ENV_HELP = "Environment name (interactive picker: existing or new, if omitted)." _SET_DEFAULT_HELP = "Set this environment as the default in the config file (prompted if omitted)." @@ -32,14 +29,12 @@ def _resolve_params( config_env: str | None, scope: str | None, set_default: bool | None, config_file: Path ) -> tuple[str, str, bool] | None: - """Resolve env/scope/set-default, or return ``None`` (after printing "Login aborted.") on cancel.""" + """Resolve env/scope/set-default, or print "Login aborted." and return ``None`` on cancel.""" config_env = resolve_config_env(config_env, config_file) scope = resolve_scope(scope) - if config_env is None or scope is None: - print("Login aborted.", file=sys.stderr) - return None - set_default = resolve_set_default(set_default, config_env) - if set_default is None: + # Only prompt for set-default once env and scope are settled, so a cancel there doesn't prompt. + set_default = resolve_set_default(set_default, config_env) if config_env and scope else None + if config_env is None or scope is None or set_default is None: print("Login aborted.", file=sys.stderr) return None return config_env, scope, set_default diff --git a/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py b/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py index 88196873..ed54aa43 100644 --- a/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py +++ b/tests/bfabric_scripts/cli/login/test_cmd_auth_default.py @@ -65,23 +65,16 @@ def test_cancelled_picker_leaves_file_unchanged(self, tmp_path, mocker, capsys): assert config_file.read_text() == before -class TestNonInteractiveListing: - def test_lists_environments_when_no_tty_and_no_value(self, tmp_path, mocker, capsys): +class TestNonInteractiveNoValue: + def test_reports_no_value_and_leaves_file_unchanged(self, tmp_path, mocker, capsys): config_file = tmp_path / "config.yml" _write_config(config_file, default="PROD") before = config_file.read_text() mocker.patch("bfabric_scripts.cli.login.manage.is_interactive", return_value=False) cmd_auth_default(config_file=config_file) output = capsys.readouterr().out - assert "PROD" in output - assert "TEST" in output - # The current default is still marked prominently. - assert "→ PROD" in output - # Each row shows the host and auth method so the environments are distinguishable. - assert "prod.example.com" in output - assert "oauth" in output - assert "test.example.com" in output - assert "pat" in output + # Without a TTY to prompt on, it can't pick for the user: report and change nothing. + assert "No environment specified" in output assert config_file.read_text() == before def test_empty_config_reports_no_environments(self, tmp_path, capsys): From 717089393ceefa95d3f68d8f29d2f4e8785bea6d Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Fri, 17 Jul 2026 11:45:12 +0200 Subject: [PATCH 28/29] Update changelog.md --- bfabric_scripts/docs/changelog.md | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/bfabric_scripts/docs/changelog.md b/bfabric_scripts/docs/changelog.md index 686fb738..ce89448c 100644 --- a/bfabric_scripts/docs/changelog.md +++ b/bfabric_scripts/docs/changelog.md @@ -10,23 +10,11 @@ Versioning currently follows `X.Y.Z` semantic versioning, independent of the `bf ## \[Unreleased\] -- `auth login` / `auth device-code` scope selection: named presets — `read-only` (`api:read`), `read-write` (`api:write`), `upload` (`api:write tus`) — offered via an interactive picker (plus a Custom option for a raw scope string) when `--scope` is omitted in a terminal; a preset name or a raw scope string can also be passed explicitly. There is no baked-in default scope, so a non-interactive run must pass `--scope`. Client/webapp registration (`auth register` / `auth register-webapp`) keeps the OIDC-inclusive default, which webapps need for user-identity claims. -- `auth login` / `auth device-code` / `auth pat`: cancelling the interactive "set as default?" prompt (Ctrl-C) now aborts the login instead of silently proceeding as if `--no-set-default` were given. - -## \[1.16.0rc2\] - 2026-07-15 - -- `bfabric-cli auth` command group for OAuth authentication and client management: - - Login: `auth login` (browser), `auth device-code` (headless), `auth pat` (Personal Access Token). When `--config-env` is omitted the command prompts for the environment (pick an existing one or type a new name, prefilled with the current default); non-interactively it targets the current default env, else `PRODUCTION`. `auth login` / `auth device-code` accept `--scope` as a named preset (`read-only` / `read-write` / `upload`, the last adding the `tus` upload scope) or a raw scope string, with an interactive picker (plus a Custom option) when omitted. The presets are minimal API scopes and do **not** include `groups` or the OIDC scopes — for employee file access, request `groups` explicitly (`--scope "api:write groups"` or the Custom option). Unless `--set-default` / `--no-set-default` is given, the command asks (default yes) whether the new environment should become the config default. - - `auth register` / `auth register-webapp` — dynamic client registration, optionally with a linked B-Fabric app. - - `auth default [CONFIG_ENV]` — set the default environment; an arrow-key interactive picker (navigate the list or type to filter, Enter to select; each row shows the host and auth method) opens when no value is given, or lists the environments in a non-interactive context. - - `auth list` — list the configured environments (host + auth method), marking the default. - - `auth status` — for an OAuth environment now also reports the cached token's freshness (present / expired / expires-in) and the granted scope, annotated with the matching named preset. `auth logout [CONFIG_ENV]` removes an environment entirely (its config entry and any cached OAuth tokens): an interactive picker opens when the environment is omitted, and a confirmation prompt guards the deletion (`--no-confirm` to skip; required to remove non-interactively). - - `auth pat` stores the token under a `pat` key with `auth_method: pat` (not `login: __oauth__` / `password:`), keeping the shared config parseable by older (≤1.19.0) clients; `auth status` reports these as `pat`. -- `bfabric-cli workunit upload FILES...` — upload files and directories to a workunit over tus (resumable, large-file capable), creating a new workunit or targeting `--workunit-id`. One resource per file, skips duplicates (unless `--force`), expands directories, live progress (`--no-progress`); `--track-job` records an `UPLOAD` job. Requires an OAuth client with the `tus` scope. -- `bfabric-cli api create` / `api update` accept `--format json|yaml|tsv|table_rich` (default `json`), matching `api read`. -- `bfabric-cli dataset update` — update an existing dataset with a change preview before confirming (`csv`/`tsv`/`xlsx`/`parquet`, same validation flags as `dataset upload`). -- `api create` / `api update` now emit valid JSON (was Python `repr` via `rich.pretty.pprint`, breaking `jq`) and serialise `datetime` / `Decimal` to strings instead of raising `TypeError` ([#503](https://github.com/fgcz/bfabricPy/issues/503)). -- Internal: `dataset upload` and `bfabric_save_csv2dataset.py` now use `bfabric.operations.dataset.create_dataset`; API error handling is centralized in `@use_client` (no more `@logger.catch`); `--config-env` naming unified. Declares `lxml` as an explicit dependency (was transitive via the now-optional `zeep`) and requires `bfabric[transfer]` >= 1.20 (for `upload_files` / tus). Adds `questionary` for interactive CLI prompts, wrapped in a reusable `cli.interactive` helper (`resolve_choice` / `select_choice` / `select_or_input` / `text_input` / `confirm`); adds `config_writer.remove_environment_from_config`. +- `bfabric-cli auth` — OAuth authentication & client management. Login: `login` (browser), `device-code` (headless), `pat`; client registration: `register` / `register-webapp`; environment management: `default`, `list`, `status`, `logout`. Scope presets (`read-only` / `read-write` / `upload`) or a raw scope, via an interactive picker when `--scope` is omitted in a terminal; no baked-in default scope, so a headless run must pass `--scope` (registration keeps the OIDC-inclusive default webapps need). When `--config-env` is omitted it prompts for the environment (else targets the current default / `PRODUCTION`); unless `--set-default` / `--no-set-default` is given it asks (default yes) whether to make the env the default, and cancelling that prompt aborts the login. `status` reports an OAuth env's cached-token freshness and granted scope (annotated with the matching preset); `logout` removes an env's config entry and cached tokens (confirmation required). PATs are stored under a `pat` key (`auth_method: pat`), keeping the config parseable by ≤1.19.0 clients. +- `bfabric-cli workunit upload FILES...` — upload files/directories to a workunit over tus (resumable, large-file capable): new or `--workunit-id`, one resource per file, skips duplicates (`--force`), live progress (`--no-progress`), optional `--track-job`. Requires an OAuth client with the `tus` scope. +- `bfabric-cli api create` / `api update` — accept `--format json|yaml|tsv|table_rich` (default `json`); now emit valid JSON and serialise `datetime` / `Decimal` (was Python `repr`, breaking `jq`) ([#503](https://github.com/fgcz/bfabricPy/issues/503)). +- `bfabric-cli dataset update` — update an existing dataset with a change preview before confirming (`csv`/`tsv`/`xlsx`/`parquet`). +- Internal: `dataset upload` / `bfabric_save_csv2dataset.py` use `bfabric.operations.dataset.create_dataset`; API error handling centralized in `@use_client` (dropped `@logger.catch`); `--config-env` naming unified; `lxml` now an explicit dep (was transitive via optional `zeep`); requires `bfabric[transfer]>=1.20`; adds `questionary`-based `cli.interactive` helpers and `config_writer.remove_environment_from_config`. ## \[1.15.0\] - 2026-04-20 From 4eeb2a81b175df7e2069ffbb1c5780a95e9416f9 Mon Sep 17 00:00:00 2001 From: Leonardo Schwarz Date: Fri, 17 Jul 2026 11:56:02 +0200 Subject: [PATCH 29/29] Update changelog.md --- bfabric_scripts/docs/changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bfabric_scripts/docs/changelog.md b/bfabric_scripts/docs/changelog.md index ce89448c..aca41ed2 100644 --- a/bfabric_scripts/docs/changelog.md +++ b/bfabric_scripts/docs/changelog.md @@ -10,6 +10,8 @@ Versioning currently follows `X.Y.Z` semantic versioning, independent of the `bf ## \[Unreleased\] +## \[1.16.0rc2\] - 2026-07-15 + - `bfabric-cli auth` — OAuth authentication & client management. Login: `login` (browser), `device-code` (headless), `pat`; client registration: `register` / `register-webapp`; environment management: `default`, `list`, `status`, `logout`. Scope presets (`read-only` / `read-write` / `upload`) or a raw scope, via an interactive picker when `--scope` is omitted in a terminal; no baked-in default scope, so a headless run must pass `--scope` (registration keeps the OIDC-inclusive default webapps need). When `--config-env` is omitted it prompts for the environment (else targets the current default / `PRODUCTION`); unless `--set-default` / `--no-set-default` is given it asks (default yes) whether to make the env the default, and cancelling that prompt aborts the login. `status` reports an OAuth env's cached-token freshness and granted scope (annotated with the matching preset); `logout` removes an env's config entry and cached tokens (confirmation required). PATs are stored under a `pat` key (`auth_method: pat`), keeping the config parseable by ≤1.19.0 clients. - `bfabric-cli workunit upload FILES...` — upload files/directories to a workunit over tus (resumable, large-file capable): new or `--workunit-id`, one resource per file, skips duplicates (`--force`), live progress (`--no-progress`), optional `--track-job`. Requires an OAuth client with the `tus` scope. - `bfabric-cli api create` / `api update` — accept `--format json|yaml|tsv|table_rich` (default `json`); now emit valid JSON and serialise `datetime` / `Decimal` (was Python `repr`, breaking `jq`) ([#503](https://github.com/fgcz/bfabricPy/issues/503)).