# mgf-fastapi v0.3.0 — `bearer_session` + `AuthVerifier` cutover guide

> **Audience:** mgf-fastapi consumers upgrading from v0.2.0 (paired
> with mgf-common v0.30) to v0.3.0 (paired with mgf-common v0.31).
> Closes FEEDBACK [PAPER-29](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/FEEDBACK.md)
> — `bearer_session` + `AuthVerifier` for the "Authorization: Bearer →
> typed session principal" gate every authenticated route needs.

The change in mgf-fastapi v0.3.0 is **purely additive** plus a
single dep-pin bump. Nothing existing changes shape.

The pin bumps `mgf-common>=0.30,<0.31` → `mgf-common>=0.31,<0.32`,
in lock-step with mgf-common's v0.31 release (AP-03 0.x window
discipline; one minor at a time).

---

## At-a-glance

| Was (mgf-fastapi v0.2.0) | Is (mgf-fastapi v0.3.0) |
|---|---|
| 0 auth primitives in `mgf.fastapi.security` (only `IpAllowlist`) | `bearer_session` factory + `AuthVerifier` Protocol |
| Each consumer reinvents `Authorization: Bearer → SessionPrincipal` (~30 LOC + tests) | `Annotated[Principal, Depends(bearer_session())]` — one line |
| `mgf-common>=0.30,<0.31` | `mgf-common>=0.31,<0.32` |
| (everything else) | unchanged |

---

## What's new

### `mgf.fastapi.security.bearer_session(verifier_attr, *, cookie_name=None)`

A factory. Call it once at app-construction time and bind the result
as the route's dependency:

```python
from typing import Annotated
from fastapi import Depends, FastAPI
from mgf.fastapi import ExceptionTranslationMiddleware
from mgf.fastapi.security import bearer_session

from my_app.auth import SessionPrincipal, my_verifier

app = FastAPI()
app.add_middleware(ExceptionTranslationMiddleware)
app.state.auth_provider = my_verifier   # any AuthVerifier impl

Session = Annotated[SessionPrincipal, Depends(bearer_session())]

@app.get("/me")
async def me(session: Session) -> dict[str, str]:
    return {"user": session.email}
```

The dependency:

- Reads the `AuthVerifier` from `app.state.<verifier_attr>` (default
  attribute `auth_provider`).
- Reads the bearer token from the `Authorization` header. Case-
  insensitive scheme match (`Bearer` / `bearer` / `BEARER`).
- Optionally falls back to a named cookie (browser flows).
- Calls `await verifier.verify_session(token)` and returns the
  principal.
- Translates every failure mode to `HttpUnauthorized` → 401.

### `mgf.fastapi.security.AuthVerifier[P]`

The Protocol you implement. Pick `P` to be whatever your app uses
as its session principal:

```python
from dataclasses import dataclass
from mgf.fastapi.security import AuthVerifier

@dataclass(frozen=True)
class SessionPrincipal:
    user_id: str
    email: str
    tenant_id: str

class ClerkVerifier:
    """One concrete impl. Substitute Authentik / Auth0 / your own."""

    async def verify_session(self, token: str) -> SessionPrincipal:
        # Call out to Clerk's verification API; map JWT claims to
        # the principal; raise anything on failure.
        ...

# Structural type-check — no inheritance needed:
verifier: AuthVerifier[SessionPrincipal] = ClerkVerifier()
```

`AuthVerifier` is covariant in `P` so `AuthVerifier[Subclass]` is
assignable to `AuthVerifier[Base]` where appropriate.

---

## Why opt into it

| Before (per-consumer impl) | After (`bearer_session`) |
|---|---|
| ~30 LOC per app + tests for the same bearer-extraction + 401 translation logic | one `Annotated[…, Depends(bearer_session())]` line per route |
| Constant-time comparison + scheme parsing + cookie fallback duplicated across apps | concentrated in one tested module |
| 401 response shape diverges (some apps raise `HTTPException(401)`, some `HttpUnauthorized`, some return `{}`) | always `HttpUnauthorized` → JSON via `ExceptionTranslationMiddleware` |
| No canonical place to document the proxy-trust / cookie / bearer-vs-cookie precedence choices | docstring + this guide |

You can keep your existing in-app dependency if you want — `bearer_session`
is additive and doesn't replace anything. The expected migration path:
when an app pulls in mgf-fastapi v0.3, delete the local re-implementation
and switch the routes to the shared factory.

---

## Failure-mode contract

| Situation | Result |
|---|---|
| No `Authorization` header (and no cookie when `cookie_name` is set) | `HttpUnauthorized("missing bearer token")` → 401 |
| Wrong scheme (`Basic`, `Negotiate`, etc.) | `HttpUnauthorized("missing bearer token")` → 401 |
| `Authorization: Bearer ` (empty value) | `HttpUnauthorized("missing bearer token")` → 401 |
| Verifier missing on `app.state` | `HttpUnauthorized("authentication not configured")` to the caller + `LOG.error` to ops |
| Verifier raises `HttpUnauthorized` directly | pass-through (verifier's message reaches the client) |
| Verifier raises any other exception (`ValueError`, provider 5xx, network) | `HttpUnauthorized("invalid or expired session")`; original cause chained via `__cause__`; `LOG.info` records the exception type |

The 401 JSON response comes from `mgf.fastapi.ExceptionTranslationMiddleware`
via the HTTP-01 `http_status` fallback (PAPER-24) — no `status_map` entry
needed.

---

## Cookie fallback

For browser clients that store the session in an `HttpOnly` cookie:

```python
SessionFromCookieOrHeader = Annotated[
    SessionPrincipal,
    Depends(bearer_session(cookie_name="session")),
]
```

The Authorization header takes precedence; the cookie is consulted
only when no Bearer header is present.

---

## Testing pattern

Tests swap the verifier by mutating `app.state` before sending the
request. Returning a fake principal is one line:

```python
class _FakeVerifier:
    async def verify_session(self, token: str) -> SessionPrincipal:
        if token == "test-token":
            return SessionPrincipal(user_id="u1", email="alice@example.org", tenant_id="t1")
        raise HttpUnauthorized("unknown test token")

app.state.auth_provider = _FakeVerifier()
client = TestClient(app)
response = client.get("/me", headers={"Authorization": "Bearer test-token"})
assert response.status_code == 200
```

See `tests/unit/test_security.py` in this repo for the full pattern.

---

## Pin bump only — no code changes required

If you don't yet need `bearer_session` but want to land on
mgf-fastapi v0.3 (e.g. to consume mgf-common v0.31's pure-Python
core fixes), the only change is the dep pin:

```toml
# pyproject.toml
dependencies = [
    "mgf-common>=0.31,<0.32",
    "mgf-fastapi>=0.3.0,<0.4",
]
```

No runtime change. Existing `IpAllowlist`, `RequestIdMiddleware`,
`ExceptionTranslationMiddleware`, `setup_lifespan`, `get_session`,
`setup_db`, `HmacWebhookVerifier`, and the rest of v0.2's surface
are unchanged.

---

## Federation context

mgf-common is in its v0.32+ stabilization window: no new public
surface in mgf-common itself. Siblings retain autonomy to add new
public surface in their own minors (per the federation roadmap's
decision-log entries on independent semver). mgf-fastapi v0.3
exercises that autonomy — the addition is `mgf-fastapi`-only and
doesn't require any mgf-common change.
