Metadata-Version: 2.4
Name: bloonio-auth-relay-client
Version: 0.3.1
Summary: Client SDK for the bloonio_auth relay. Backend integration for push-based sudo-action approval (TOTP / golden number / biometric) via the bloonio_auth authenticator app. Framework-agnostic core + thin FastAPI / Django adapters.
Author: Bloonio
License-Expression: LicenseRef-Proprietary
Project-URL: Repository, https://github.com/Bloonio/bloonio_auth_relay_client
Keywords: bloonio,authentication,sudo,2fa,push-approval,totp,biometric
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Security
Classifier: Topic :: System :: Systems Administration :: Authentication/Directory
Classifier: Operating System :: POSIX
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.6
Requires-Dist: pydantic-settings>=2.2
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.110; extra == "fastapi"
Requires-Dist: starlette>=0.36; extra == "fastapi"
Provides-Extra: django
Requires-Dist: django>=4.2; extra == "django"
Provides-Extra: redis
Requires-Dist: redis>=5.0; extra == "redis"
Provides-Extra: mongo
Requires-Dist: motor>=3.4; extra == "mongo"
Provides-Extra: all
Requires-Dist: fastapi>=0.110; extra == "all"
Requires-Dist: starlette>=0.36; extra == "all"
Requires-Dist: django>=4.2; extra == "all"
Requires-Dist: redis>=5.0; extra == "all"
Requires-Dist: motor>=3.4; extra == "all"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: pytest-cov>=5.0; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Requires-Dist: fakeredis>=2.20; extra == "dev"
Requires-Dist: fastapi>=0.110; extra == "dev"
Requires-Dist: django>=4.2; extra == "dev"
Requires-Dist: djangorestframework>=3.14; extra == "dev"
Requires-Dist: redis>=5.0; extra == "dev"
Dynamic: license-file

# bloonio_auth_relay_client

Backend SDK for the **bloonio_auth** relay (`auth-relay.example.com`).

Integrate push-based sudo approval (TOTP / golden number / biometric via the **bloonio_auth** authenticator app) into any Python backend in ~10 lines. Framework-agnostic core, thin FastAPI + Django adapters.

## Install

```bash
pip install "bloonio-auth-relay-client[fastapi,redis]"
# or
pip install "bloonio-auth-relay-client[django,redis]"
```

Available extras: `fastapi`, `django`, `redis`, `mongo`, `all`.

## Concepts

- **`RelayClient` / `AsyncRelayClient`** — HMAC-signed HTTP wrapper around the relay. Sync version for Django, async for FastAPI; same surface.
- **`SudoInstructionStore`** — short-lived (~180 s default). Tracks "this instruction passed sudo, here's the re-call window with `X-Sudo-Instruction-Key`". Default: Redis.
- **`PendingOpStore`** — longer-lived (10–30 min). Holds the original mutation for **server-side replay** after approval — required for v2 group quorum + v3 cross-org modes. v1 single-actor doesn't use it but the interface ships now to avoid future API breakage.
- **`@sudo_required`** — decorator. Same arguments work in FastAPI and Django.
- **`DataType` + `ValidationField`** — structured display blocks the auth app renders (currency, IBAN, date, entity ref, etc.). Replaces unstructured description strings.

## FastAPI quick start — pick the style that fits your codebase

### Style A — declarative (greenfield, named routes)

For backends where each sudo-protected route is named and the developer knows
up-front what fields to display:

```python
from fastapi import FastAPI
from bloonio_auth_relay_client import DataType, SudoActionType, ValidationField
from bloonio_auth_relay_client.adapters.fastapi import BloonioAuthAdapter

app = FastAPI()
bloonio = BloonioAuthAdapter.from_env(app)   # reads BLOONIO_RELAY_* env

@app.post(
    "/transfer/execute",
    dependencies=[bloonio.sudo_required(
        expected_action="transfer_funds",
        custom_type=SudoActionType.LOCAL_AUTH,
        user_socket_hash=lambda req: req.state.user.socket_hash,
        title="Confirm wire transfer",
        fields=lambda req: [
            ValidationField(key="amount", title="Amount",
                            value=str(req.state.body["amount"]),
                            data_type=DataType.CURRENCY_USD),
            ValidationField(key="to", title="Recipient",
                            value=req.state.body["recipient"],
                            data_type=DataType.PARTY_NAME),
        ],
    )],
)
async def transfer_execute(...): ...
```

`BloonioAuthAdapter.from_env(app)` does all of:
- builds `RelaySettings` from `BLOONIO_RELAY_*` env vars
- builds an `AsyncRelayClient`
- builds Redis-backed instruction + pending-op stores
- mounts the dispatch middleware
- mounts the auto submit-response router at `/_bloonio/submit-response`
- mounts the relay callback router at `settings.callback_path`

### Style B — RBAC-driven (dynamic / generic routes)

For backends where which-endpoint-needs-sudo is determined at runtime by querying
RBAC config (e.g. routes like `/generic/add/{collection_name}`), pass a
`sudo_resolver` callable. The resolver returns a `SudoInfo` describing what to
challenge with — the SDK takes it from there.

```python
from bloonio_auth_relay_client import (
    DataType, SudoActionType, SudoInfo, ValidationField, ValidationMode, Validator,
)
from bloonio_auth_relay_client.adapters.fastapi import BloonioAuthAdapter
from starlette.requests import Request

async def my_rbac_resolver(request: Request) -> SudoInfo | None:
    """Backend writes only this — the rest is the SDK."""
    rbac = await fetch_rbac_for_path(request.url.path)
    if not rbac or not rbac.is_sudo_action:
        return None

    user = request.state.user
    return SudoInfo(
        required=True,
        mode=ValidationMode.SINGLE_ACTOR,
        custom_type=pick_random_confirmation_type(rbac),
        expected_action=rbac.expected_action,
        description=rbac.totp_app_description_str,
        actor=Validator(
            socket_hash=user.user_account_socket_hash,
            display_name=f"{user.first_name} {user.last_name}",
        ),
        display_title="Confirm action",
        display_fields=build_fields_from_request(request),
    )

bloonio = BloonioAuthAdapter.from_env(app, sudo_resolver=my_rbac_resolver)

# All routes — including generic ones — are now sudo-aware.
# When required=True, the SDK:
#   • Creates an instruction_id
#   • Writes Redis state (180s TTL by default)
#   • Calls relay.send_auth_challenge(...) → FCM push
#   • Returns 403 with {error: "SUDO_INSTRUCTION_KEY_REQUIRED", instruction_id}
# When the device approves (POST /_bloonio/submit-response), state flips to
# "validated". The next call with X-Sudo-Instruction-Key passes through.
```

The two styles can coexist in the same app — declarative for explicit routes,
resolver for catch-all ones.

## Django quick start

```python
# settings.py
INSTALLED_APPS = [..., "bloonio_auth_relay_client.adapters.django"]
MIDDLEWARE = [..., "bloonio_auth_relay_client.adapters.django.middleware.SudoActionMiddleware"]

BLOONIO_AUTH_RELAY = {
    "BASE_URL": "https://auth-relay.example.com",
    "TENANT_ID": os.environ["RELAY_TENANT_ID"],
    "TENANT_SECRET": os.environ["RELAY_TENANT_SECRET"],
    "STATE_BACKEND": "redis",
    "STATE_BACKEND_URL": os.environ["REDIS_URL"],
    "CALLBACK_PATH": "/api/sudo-callback/",
}
```

```python
# urls.py
from bloonio_auth_relay_client.adapters.django import urls as relay_urls
urlpatterns = [..., path("", include(relay_urls))]
```

```python
# views.py
from bloonio_auth_relay_client.adapters.django import sudo_required
from bloonio_auth_relay_client import DataType, SudoActionType, ValidationField

@sudo_required(
    expected_action="transfer_funds",
    custom_type=SudoActionType.LOCAL_AUTH,
    user_socket_hash=lambda req: req.user.socket_hash,
    title="Confirm wire transfer",
    fields=lambda req: [
        ValidationField(key="amount", title="Amount",
                        value=str(req.POST["amount"]),
                        data_type=DataType.CURRENCY_USD),
    ],
)
def transfer_execute(request):
    ...
```

Works on plain views, DRF `@api_view`, DRF `APIView`/`ViewSet` methods, and async views (Django 4.1+). Same decorator, same arguments — just `req.user` instead of `req.state.user`, `req.POST` instead of `req.state.body`.

## Pairing handshake (called once per device pairing)

```python
from bloonio_auth_relay_client import AsyncRelayClient, RelaySettings

relay = AsyncRelayClient(RelaySettings())

# In your QR pairing handler, after the user is authenticated:
result = await relay.prepare_pairing(
    user_socket_hash=user.socket_hash,
    backend_user_id=str(user.id),
    user_email=user.email,
    user_phone=user.phone,
    first_name=user.first_name,
    last_name=user.last_name,
    display_name="bloonio_apps_api",
    display_logo_url="https://cdn.example.com/logo.png",
)
pairing_proof = result["pairing_proof"]   # forward to device along with the rest of /auth/get-pairing-data
```

## Two-call protocol (preserved from existing flow)

1. Client `POST /transfer/execute` with no `X-Sudo-Instruction-Key`.
   → 403 `{"error": "SUDO_INSTRUCTION_KEY_REQUIRED", "instruction_id": "abc..."}`
2. Device receives push → user approves → relay POSTs ApprovalEvent to your callback (HMAC-verified) → `SudoInstructionStore` marks `instruction_id` as `validated`.
3. Client re-issues `POST /transfer/execute` with header `X-Sudo-Instruction-Key: abc...`.
   → request goes through.

## v2 / v3 (deferred)

The decorator accepts `mode=ValidationMode.GROUP_QUORUM` etc. but raises `NotImplementedError` until v2/v3 land. The data-model and types are stable.

## Configuration via env

All settings can be passed to `RelaySettings(...)` directly or sourced from env (`BLOONIO_RELAY_BASE_URL`, `BLOONIO_RELAY_TENANT_ID`, `BLOONIO_RELAY_TENANT_SECRET`, `BLOONIO_RELAY_STATE_BACKEND_URL`, etc.).
