Skip to content
Closed
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
16 changes: 0 additions & 16 deletions .basedpyright/baseline.bfabric.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,6 @@
"lineCount": 1
}
},
{
"code": "reportCallInDefaultInitializer",
"range": {
"startColumn": 39,
"endColumn": 63,
"lineCount": 1
}
},
{
"code": "reportUnusedParameter",
"range": {
Expand Down Expand Up @@ -243,14 +235,6 @@
"lineCount": 1
}
},
{
"code": "reportAny",
"range": {
"startColumn": 48,
"endColumn": 53,
"lineCount": 1
}
},
{
"code": "reportExplicitAny",
"range": {
Expand Down
106 changes: 0 additions & 106 deletions .basedpyright/baseline.bfabric_scripts.json
Original file line number Diff line number Diff line change
Expand Up @@ -2178,56 +2178,6 @@
}
}
],
"./bfabric_scripts/src/bfabric_scripts/cli/__main__.py": [
{
"code": "reportUnusedCallResult",
"range": {
"startColumn": 0,
"endColumn": 32,
"lineCount": 1
}
},
{
"code": "reportUnusedCallResult",
"range": {
"startColumn": 0,
"endColumn": 40,
"lineCount": 1
}
},
{
"code": "reportUnusedCallResult",
"range": {
"startColumn": 0,
"endColumn": 46,
"lineCount": 1
}
},
{
"code": "reportUnusedCallResult",
"range": {
"startColumn": 0,
"endColumn": 42,
"lineCount": 1
}
},
{
"code": "reportUnusedCallResult",
"range": {
"startColumn": 0,
"endColumn": 38,
"lineCount": 1
}
},
{
"code": "reportUnusedCallResult",
"range": {
"startColumn": 0,
"endColumn": 51,
"lineCount": 1
}
}
],
"./bfabric_scripts/src/bfabric_scripts/cli/api/create.py": [
{
"code": "reportUnknownParameterType",
Expand Down Expand Up @@ -2459,69 +2409,13 @@
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 24,
"endColumn": 46,
"lineCount": 1
}
},
{
"code": "reportUnusedCallResult",
"range": {
"startColumn": 8,
"endColumn": 38,
"lineCount": 1
}
},
{
"code": "reportUnusedParameter",
"range": {
"startColumn": 4,
"endColumn": 17,
"lineCount": 1
}
},
{
"code": "reportUnknownMemberType",
"range": {
"startColumn": 16,
"endColumn": 29,
"lineCount": 1
}
},
{
"code": "reportUnknownMemberType",
"range": {
"startColumn": 16,
"endColumn": 29,
"lineCount": 1
}
},
{
"code": "reportUnknownMemberType",
"range": {
"startColumn": 16,
"endColumn": 29,
"lineCount": 1
}
},
{
"code": "reportUnknownMemberType",
"range": {
"startColumn": 16,
"endColumn": 29,
"lineCount": 1
}
},
{
"code": "reportUnknownArgumentType",
"range": {
"startColumn": 23,
"endColumn": 29,
"lineCount": 1
}
}
],
"./bfabric_scripts/src/bfabric_scripts/cli/api/update.py": [
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ Each package's docs live alongside its source. Skim the index when working in a
## Key Conventions

- Tests must NOT contain `__init__.py` files (enforced by `check_test_inits` nox session)
- Test order is randomised via [pytest-random-order](https://github.com/pytest-dev/pytest-random-order) (`addopts = "--random-order"`), so both modules and the tests within them run in a shuffled order to surface hidden inter-test dependencies. The seed is printed in the pytest header; reproduce a specific order with `pytest --random-order-seed=<N>`. Tests must therefore be isolation-safe; quarantine a genuinely order-dependent module with `pytestmark = pytest.mark.random_order(disabled=True)` only as a last resort.
- Test conftest sets `BFABRICPY_CONFIG_ENV=__MOCK` to avoid real credentials
- Ruff linting is currently only enforced on the `bfabric` package (scripts, wrapper_creator, tests, noxfile are excluded via per-file-ignores)
- Line length: 120 (ruff and black)
Expand All @@ -107,3 +108,4 @@ Each package's docs live alongside its source. Skim the index when working in a

- `main` — active development
- `release` — triggers PyPI publish on merge
- When pushing, give the remote branch a reasonable, descriptive name even if the local branch has an auto-generated worktree name (e.g. `worktree-quiet-gathering-mitten`) — push with an explicit remote ref: `git push -u origin HEAD:feature/short-descriptive-name`.
23 changes: 23 additions & 0 deletions bfabric/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,29 @@ Minor breaking changes are still possible in `1.X.Y` but we try to announce them

## \[Unreleased\]

### Added

- OAuth 2.0 authentication for the `Bfabric` client. New factory methods:
- `Bfabric.connect_oauth` — OAuth 2.0 client-credentials grant for service accounts / background jobs, with automatic token refresh and an optional on-disk token cache.
- `Bfabric.connect_pkce` — interactive browser login via Authorization Code + PKCE (RFC 7636).
- `Bfabric.connect_device_code` — headless interactive login via the Device Authorization Grant (RFC 8628).
- `Bfabric.connect_pat` — authenticate with an opaque Personal Access Token (bearer token; not verified locally and not auto-refreshed).
- `Bfabric.connect` now routes to OAuth automatically when the selected config environment sets `auth_method: oauth`, loading cached tokens from disk.
- `WebappClient` and `WebappClient.create` — a dual-identity client bundling a `user` identity (from a B-Fabric URL token, via RFC 8693 token exchange) and a `service` identity (client-credentials), for applications launched from B-Fabric.
- `BfabricOAuthError` in `bfabric.errors`, raised when an OAuth operation fails (token exchange, device-code, or PKCE flow).
- Config: new `auth_method` (`"password"` | `"oauth"`) and `client_id` fields on an environment configuration, and `bfabric.config.write_environment_to_config` to create/update an environment section in `~/.bfabricpy.yml` with atomic, `0o600`-permission writes.
- `bfabric.config.DEFAULT_CONFIG_FILE` constant for the default config path (`~/.bfabricpy.yml`).
- New dependencies `authlib` and `joserfc` for the OAuth flows and JWT verification.

The OAuth primitives live in a private `bfabric._oauth` module; it is internal and not yet a stable public API — use the `Bfabric.connect_*` methods above.

### Changed

- `BfabricAuth` no longer requires the password to be exactly 32 characters for OAuth/PAT logins (the `__oauth__` sentinel login); password logins still require the 32-character key.
- `Bfabric` now preserves its OAuth credential provider across pickling, so OAuth-authenticated clients keep working after a pickle round-trip (e.g. in FastAPI workers).
- `ResultContainer.assert_success` now raises `RuntimeError` with a single human-readable message (`"Query was not successful: ..."`) instead of a `(message, errors)` tuple.
- The `@use_client` CLI decorator now catches `ValueError`/`RuntimeError` raised while connecting or inside the wrapped command, printing a concise `Error: ...` to stderr and exiting with status 1 instead of dumping a traceback.

## \[1.19.0\] - 2026-06-10

### Added
Expand Down
2 changes: 2 additions & 0 deletions bfabric/docs/design/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ These are contributor-facing references, not user guides. If you're looking for
```{toctree}
:maxdepth: 1
operations_module
oauth_integration
```

## Pages

| Topic | Scope |
| --- | --- |
| **[`bfabric.operations` Module](operations_module.md)** | Conventions and worked examples for named write capabilities (`create_workunit`, `create_dataset`, …), including the failure-cleanup pattern and the audit-vs-authorization split. |
| **[OAuth Integration](oauth_integration.md)** | The `bfabric._oauth` module and OAuth 2.0 flows (PKCE, device code, client credentials, URL token, PAT), the `Bfabric.connect_*` factory methods, `bfabric-cli auth` commands, and the config/scope model. |
136 changes: 136 additions & 0 deletions bfabric/docs/design/oauth_integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# OAuth Integration

## Overview

Adds OAuth 2.0 support to bfabricPy. The library can now authenticate via PKCE, device code, client credentials, URL tokens, and personal access tokens — in addition to the existing password-based SOAP auth. All OAuth flows are transparent to downstream code: the SOAP engine receives `BfabricAuth(login="__oauth__", password=<jwt>)` automatically.

---

## New: `bfabric._oauth` module

Private module under `bfabric/src/bfabric/_oauth/` implementing all OAuth primitives.

| File | Purpose |
|------|---------|
| `_constants.py` | `DEFAULT_CLIENT_ID = "bfabric-cli"`, `DEFAULT_OAUTH_SCOPE = "api:read api:write"` |
| `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. |
| `registration.py` | `register_client()` — RFC 7591 dynamic client registration via HTTP POST. |
| `token_cache.py` | `TokenCache` — JSON file cache at `~/.bfabric/tokens/{hash}.json` with 0o600 permissions. `compute_token_cache_path()` derives a unique path from `(base_url, client_id, env_name)`. |
| `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. |

---

## New: Factory methods on `Bfabric`

| Method | Grant type | Use case |
|--------|-----------|----------|
| `Bfabric.connect()` | auto-detect | Unchanged API. Now auto-detects `auth_method: "oauth"` in config and loads cached tokens. |
| `Bfabric.connect_oauth(client_id, client_secret, base_url)` | client_credentials | Service accounts / background jobs. |
| `Bfabric.connect_pkce(base_url, client_id)` | authorization_code + PKCE | Interactive browser login (programmatic). |
| `Bfabric.connect_device_code(base_url, client_id)` | device_code | Headless interactive login (programmatic). |
| `Bfabric.from_url_token(base_url, jwt, refresh_token)` | URL token | Webapps launched from B-Fabric. Returns `(Bfabric, UrlTokenContext)`. |
| `WebappClient.create(base_url, jwt, ..., client_id, client_secret)` | URL token + client_credentials | Dual-identity webapp client. |

---

## New: CLI commands (`bfabric-cli auth`)

All commands registered under `bfabric-cli auth` via cyclopts.

| Command | File | Description |
|---------|------|-------------|
| `auth pkce <base_url>` | `cli/login/pkce.py` | Browser-based OAuth login. Caches tokens + writes config. |
| `auth device-code <base_url>` | `cli/login/device_code.py` | Headless OAuth login. |
| `auth pat <base_url>` | `cli/login/pat.py` | Personal Access Token login. |
| `auth register <client_name> <redirect_uri>` | `cli/login/register.py` | RFC 7591 dynamic client registration. Outputs JSON. |
| `auth status` | `cli/login/status.py` | Show current auth status for an environment. |
| `auth logout` | `cli/login/logout.py` | Clear cached OAuth tokens. |

All auth commands use `--config-env` (consistent with API commands via `@use_client`).

### `auth register` enhancements

- `--config-env` reuses a cached OAuth token from an existing environment (no manual bearer token needed)
- `--config-file` specifies the config file path
- `base_url` is optional when `--config-env` is provided (inferred from config)
- Token resolution: explicit `--token` > cached OAuth token via `--config-env` > interactive prompt

---

## Config file changes

### New fields in environment config

```yaml
PRODUCTION:
base_url: "https://bfabric.example.com/bfabric"
auth_method: "oauth" # NEW — triggers OAuth flow in Bfabric.connect()
client_id: "bfabric-cli" # NEW — optional, defaults to "bfabric-cli"
```

### New: `config_writer.py`

`write_environment_to_config()` — creates or updates a YAML environment section with atomic writes (0o600 permissions). Used by all login commands.

### `ConfigData` / `EnvironmentConfig`

Both models gained `auth_method` and `client_id` fields. `Bfabric.connect()` checks `auth_method == "oauth"` to route to `_connect_oauth_from_config()`.

---

## CLI error handling improvements

- `@use_client` decorator now catches `ValueError` / `RuntimeError` from `Bfabric.connect()` and from the wrapped function, printing clean error messages to stderr and exiting with code 1.
- Removed `@logger.catch(reraise=True)` from all API commands (create, read, update, delete, inspect) — error handling is now centralized in `@use_client`.
- `ResultContainer.assert_success()` now formats errors inline (`"Query was not successful: Insufficient scope..."`) instead of passing a tuple.

---

## Dependencies

- `authlib` — new dependency (added to `bfabric/pyproject.toml`)
- `httpx` — used by `registration.py` for RFC 7591 HTTP calls

---

## Test coverage

~1800 lines of new tests across 15 test files covering:

- All OAuth flows (PKCE, device code, client credentials, URL token)
- Credential provider (token refresh, thread safety, disk caching, cache priority)
- Token cache (save/load/clear, path computation, permissions)
- Client registration
- Webapp client
- All 6 CLI auth commands
- Config file parsing with new OAuth fields
- Config writer

---

## Scope enforcement (server-side)

B-Fabric now enforces OAuth scopes at the API level:
- `api:read` required for SOAP read operations
- `api:write` required for SOAP write operations
- Additional scopes (e.g. `tus`, `download`) can be requested and are enforced by their respective endpoints

The `bfabric-cli` default client is pre-registered with: `openid, profile, email, api:read, api:write, tus, download, offline_access`.

---

## How to install from this branch

```bash
# As a library dependency
pip install "bfabric @ git+https://github.com/fgcz/bfabricPy.git@oauth-integration#subdirectory=bfabric"

# As a CLI tool (both packages from branch)
uv tool install \
"bfabric-scripts @ git+https://github.com/fgcz/bfabricPy.git@oauth-integration#subdirectory=bfabric_scripts" \
--with "bfabric @ git+https://github.com/fgcz/bfabricPy.git@oauth-integration#subdirectory=bfabric" \
--reinstall
```
5 changes: 3 additions & 2 deletions bfabric/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ dependencies = [
"polars>=1.30,<2.0; platform_machine != 'x86_64' or platform_system != 'Darwin'",
"loguru>=0.7,<0.8",
"pydantic>=2.9.2,<3.0",
"eval_type_backport; python_version < '3.10'",
"python-dateutil>=2.9.0,<3.0",
"requests>=2.27.0,<3.0",
"httpx>=0.28.1,<0.29",
"annotated-types>=0.7.0",
"authlib>=1.6.9,<2.0",
"joserfc>=1.6.0,<2.0",
]

[project.optional-dependencies]
Expand All @@ -52,7 +53,7 @@ doc = [
"sphinx-autobuild>=2024.10.3",
"linkify-it-py",
]
test = ["pytest>=8", "pytest-mock>=3.15", "logot[pytest,loguru]>=1.5.1", "coverage>=7.10.0", "pytest-asyncio>=1.2.0", "dirty-equals>=0.10.0"]
test = ["pytest>=8", "pytest-mock>=3.15", "logot[pytest,loguru]>=1.5.1", "coverage>=7.10.0", "pytest-asyncio>=1.2.0", "dirty-equals>=0.10.0", "pytest-random-order>=1.1.0"]
typing = ["mypy", "types-requests", "lxml-stubs", "types-python-dateutil", "types-PyYAML"]

[project.urls]
Expand Down
25 changes: 25 additions & 0 deletions bfabric/src/bfabric/_oauth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""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
from bfabric._oauth.registration import register_client, register_webapp
from bfabric._oauth.token_exchange import exchange_token, introspect_token
from bfabric._oauth.url_token import UrlTokenContext, verify_jwt
from bfabric._oauth.webapp_client import WebappClient

__all__ = [
"DEFAULT_CLIENT_ID",
"DEFAULT_OAUTH_SCOPE",
"OAuthCredentialProvider",
"UrlTokenContext",
"WebappClient",
"device_code_login",
"exchange_token",
"introspect_token",
"pkce_login",
"register_client",
"register_webapp",
"verify_jwt",
]
Loading
Loading