Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bfabric/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.
- PKCE login: the browser callback page now renders a distinct, styled "Login failed" page showing the provider's error (e.g. a two-factor-enrollment requirement) instead of always claiming "Login successful".
- OAuth token-acquisition failures (expired/revoked refresh token, unreachable token endpoint) now raise a clear `BfabricOAuthError` instead of leaking an `authlib`/`requests` traceback.

Expand Down
3 changes: 2 additions & 1 deletion bfabric/docs/design/oauth_integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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 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.

---

## New: Factory methods on `Bfabric`
Expand Down
17 changes: 12 additions & 5 deletions bfabric/docs/design/oauth_usage_and_troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,14 @@ 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 (`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
OIDC-inclusive default.

---

Expand All @@ -92,13 +98,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
> `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(
> "https://fgcz-bfabric-demo.uzh.ch/bfabric",
> client_id="CLI",
> scope="openid profile email api:read api:write groups",
> )
> ```
Expand Down
2 changes: 1 addition & 1 deletion bfabric/docs/user_guides/bfabric-cli/workunits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions bfabric/src/bfabric/_oauth/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,8 +9,6 @@
from bfabric._oauth.webapp_client import WebappClient

__all__ = [
"DEFAULT_CLIENT_ID",
"DEFAULT_OAUTH_SCOPE",
"OAuthCredentialProvider",
"UrlTokenContext",
"WebappClient",
Expand Down
4 changes: 0 additions & 4 deletions bfabric/src/bfabric/_oauth/_constants.py

This file was deleted.

3 changes: 1 addition & 2 deletions bfabric/src/bfabric/_oauth/credential_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@

from bfabric.config.bfabric_auth import OAUTH_LOGIN, BfabricAuth
from bfabric.errors import BfabricOAuthError
from bfabric._oauth._constants import DEFAULT_OAUTH_SCOPE
from bfabric._oauth.token_cache import TokenCache

if TYPE_CHECKING:
Expand Down Expand Up @@ -66,7 +65,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,
Expand Down
5 changes: 2 additions & 3 deletions bfabric/src/bfabric/_oauth/device_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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).
Expand Down
5 changes: 2 additions & 3 deletions bfabric/src/bfabric/_oauth/pkce.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
import httpx
from loguru import logger

from bfabric._oauth._constants import DEFAULT_CLIENT_ID, DEFAULT_OAUTH_SCOPE
from bfabric.errors import BfabricOAuthError


Expand Down Expand Up @@ -180,8 +179,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,
Expand Down
10 changes: 4 additions & 6 deletions bfabric/src/bfabric/_oauth/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,8 +37,8 @@ def register_client(
client_name: str,
redirect_uri: str,
*,
scope: str,
service_user: str | None = None,
scope: str = DEFAULT_OAUTH_SCOPE,
grant_types: list[str] | None = None,
) -> dict[str, object]:
"""Register a new OAuth client with the B-Fabric server.
Expand All @@ -55,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.
"""
Expand Down Expand Up @@ -90,8 +88,8 @@ def register_webapp(
app_name: str,
web_url: str,
*,
scope: str,
service_user: str | None = None,
scope: str = DEFAULT_OAUTH_SCOPE,
application_id: int | None = None,
technology_id: int | None = None,
description: str | None = None,
Expand All @@ -108,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
Expand Down
5 changes: 2 additions & 3 deletions bfabric/src/bfabric/_oauth/webapp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -80,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,
Expand Down
19 changes: 12 additions & 7 deletions bfabric/src/bfabric/bfabric.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 login' 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():
Expand All @@ -137,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,
)
Expand Down Expand Up @@ -230,7 +235,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.
Expand Down Expand Up @@ -266,8 +271,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,
Expand Down Expand Up @@ -318,8 +323,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:
Expand Down
2 changes: 1 addition & 1 deletion bfabric/src/bfabric/examples/prove_tus_resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand Down
6 changes: 4 additions & 2 deletions bfabric/src/bfabric/transfer/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions bfabric_scripts/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Versioning currently follows `X.Y.Z` semantic versioning, independent of the `bf

## \[Unreleased\]

- `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

- `bfabric-cli auth` command group for OAuth authentication and client management:
Expand Down
3 changes: 3 additions & 0 deletions bfabric_scripts/example/test_webapp_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_REGISTRATION_SCOPE

PORT = 19876
REDIRECT_PATH = "/callback"
Expand Down Expand Up @@ -56,6 +57,7 @@ def main() -> None:
app_name=app_name,
web_url=web_url,
service_user="itfeeder",
scope=DEFAULT_REGISTRATION_SCOPE,
hidden=True,
)
oauth_info = result["oauth"]
Expand Down Expand Up @@ -149,6 +151,7 @@ def serve() -> None:
launch_token=jwt,
client_id=oauth_client_id,
client_secret=oauth_client_secret,
scope=DEFAULT_REGISTRATION_SCOPE,
)
except Exception as e:
print(f"\nERROR: Token exchange failed: {e}", file=sys.stderr)
Expand Down
37 changes: 37 additions & 0 deletions bfabric_scripts/src/bfabric_scripts/cli/login/_constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""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.
"""

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)"),
)
4 changes: 2 additions & 2 deletions bfabric_scripts/src/bfabric_scripts/cli/login/device_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@

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


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_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.")
Expand Down
Loading