# Hc Obo Auth

> Wire OBO (On-Behalf-Of) authentication into a service or BFF route. Use when the user asks to "add a route", "wire OBO", "set up authentication", "connect to Lakebase as the user", "extract user identity", or anything about user-passthrough auth. Enforces the canonical pattern where every Postgres connection is opened with the calling user's OAuth credential and Unity Catalog enforces access at the data layer.

- Skill: `databricks-solutions/hc-obo-auth` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add databricks-solutions/hc-obo-auth`
- Raw SKILL.md: https://api.skillmd.com/api/skills/databricks-solutions/hc-obo-auth/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: databricks-solutions (https://skillmd.com/u/databricks-solutions)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/databricks-solutions/hc-obo-auth

---


# hc-obo-auth

The non-negotiable auth rule in this architecture: **every Postgres session opens with the calling user's OAuth credential.** No service-principal fallback in production code, no shortcuts.

This skill is the source of truth for the canonical patterns. Any deviation needs to be argued for in a PR description.

## When to use

- "Add a new route to `patient-svc` that lists allergies"
- "Wire OBO into the BFF client for `lab-svc`"
- "How do I connect to Lakebase as the user?"
- "Set up `db.py` for the new service"
- The user is touching anything that crosses the trust boundary (HTTP route, DB connection, downstream service call)

## When NOT to use

- The user is writing pure unit tests against a mock — OBO doesn't apply.
- The user is writing a one-off ETL script — script auth is its own thing, use a service principal there but document it as outside the OBO trust path.

## The four pillars

1. **Token in.** Every backend route reads `X-Forwarded-Access-Token` from the request — Databricks Apps injects this on every call.
2. **Token to UC.** When the service needs UC operations, it builds `WorkspaceClient(token=user_token)`, never `WorkspaceClient()` without a token in production.
3. **Token to Lakebase.** When the service connects to Postgres, it calls `WorkspaceClient(token=user_token, auth_type="pat").postgres.generate_database_credential(endpoint=ENDPOINT_NAME)` and uses the returned token as the Postgres password. The `auth_type="pat"` is required when the code runs inside a Databricks App: the platform auto-injects `DATABRICKS_CLIENT_ID` + `DATABRICKS_CLIENT_SECRET` for the app's service principal, and the SDK refuses ambiguous oauth+pat config unless one method is forced. Connections are pooled with `max_lifetime=2700` (45 min) so a stale token never lives longer than 1h.
4. **Token forward.** When the BFF or any service calls another service, it copies the user token into both `Authorization: Bearer ...` and `X-Forwarded-Access-Token` headers. The downstream service treats it as if the user called directly.

The detailed canonical code (drop-in copies for every service) lives in [`references/canonical-patterns.md`](references/canonical-patterns.md).

## Quick patterns

### Extracting the user token in a FastAPI route

```python
from fastapi import Header, HTTPException

async def user_token(
    x_forwarded_access_token: str | None = Header(default=None, alias="X-Forwarded-Access-Token"),
) -> str:
    if not x_forwarded_access_token:
        raise HTTPException(401, "Missing X-Forwarded-Access-Token")
    return x_forwarded_access_token
```

Use it as a dependency:

```python
@router.get("/patients", response_model=list[PatientOut], operation_id="listPatients")
async def list_patients(token: str = Depends(user_token)):
    async with patient_db_pool(token).connection() as conn:
        ...
```

### Building a per-request DB pool

Don't build one global pool. Build one *per user token*, cached briefly. The full helper is in [`references/canonical-patterns.md`](references/canonical-patterns.md), but the shape:

```python
from psycopg_pool import AsyncConnectionPool
import psycopg
from databricks.sdk import WorkspaceClient
import os, asyncio

_pools: dict[str, AsyncConnectionPool] = {}
_lock = asyncio.Lock()

class OAuthConnection(psycopg.AsyncConnection):
    @classmethod
    async def connect(cls, conninfo: str = "", **kwargs):
        token = kwargs.pop("_user_token", None)
        if not token:
            raise RuntimeError("OAuthConnection requires _user_token kwarg")
        # auth_type="pat" disambiguates from the SP credentials Apps auto-injects.
        ws = WorkspaceClient(token=token, auth_type="pat")
        cred = ws.postgres.generate_database_credential(endpoint=os.environ["ENDPOINT_NAME"])
        kwargs["password"] = cred.token
        return await super().connect(conninfo, **kwargs)

def patient_db_pool(user_token: str) -> AsyncConnectionPool:
    # Cache pools per token (short-lived). In production you'd cache by token's iss+sub claim
    # to avoid leaking memory if many distinct tokens hit the service.
    if user_token not in _pools:
        host = os.environ["PGHOST"]
        user = os.environ["PGUSER"]
        _pools[user_token] = AsyncConnectionPool(
            conninfo=f"host={host} port=5432 dbname=databricks_postgres user={user} sslmode=require",
            connection_class=OAuthConnection,
            max_lifetime=2700,
            kwargs={"_user_token": user_token},
            open=False,
        )
    return _pools[user_token]
```

> ⚠️ The `_pools[token]` cache leaks if many distinct tokens hit the service. The full pattern in `references/` uses an LRU keyed by `hash(token)` with a TTL.

### Forwarding the token from the BFF

URL resolution: explicit `<SVC>_SVC_URL` override → runtime-derived from `DATABRICKS_APP_URL`. See `references/canonical-patterns.md` for the full `_BaseSvcClient` shape.

```python
class PatientClient(_BaseSvcClient):
    def __init__(self, user_token: str):
        super().__init__(user_token, service_slug="patient")
```

The BFF route extracts the token the same way as services and constructs one client per request:

```python
@router.get("/patient-summary/{patient_id}", operation_id="getPatientSummary")
async def patient_summary(patient_id: str, token: str = Depends(user_token)):
    async with PatientClient(token) as p, AppointmentClient(token) as a:
        patient, appts = await asyncio.gather(
            p.get(patient_id),
            a.list_for_patient(patient_id, limit=3),
        )
    return {"patient": patient, "appointments": appts}
```

## Local development

When running `apx dev start` outside Databricks Apps, there's no `X-Forwarded-Access-Token` injected. Two options, both supported:

1. **Pass-through from the dev's CLI auth.** The `apx` lakebase addon can inject the dev's token into the env so requests pretend to be OBO. The dev's identity is then used for Lakebase. Set `LOCAL_DEV_TOKEN_FROM_CLI=true` in `.env` and the canonical pattern reads it.

2. **Explicit header.** When testing with `curl`, include `-H "X-Forwarded-Access-Token: $(databricks auth token -p hc-dev | jq -r .access_token)"`.

What the canonical pattern **does not** support: a service-principal fallback when running locally. Local dev still uses the developer's user identity to talk to Lakebase. This keeps prod-vs-dev behavior identical and prevents "works on my machine, fails in prod" auth bugs.

## Anti-patterns to reject in code review

| Pattern | Why it's wrong |
|---|---|
| `WorkspaceClient()` (no token) in route handlers | Falls back to the app's service principal, defeats OBO |
| Storing a connection pool keyed only on env (one global pool) | All users share one connection → all queries run as the SP |
| Reading the token from a query param or cookie instead of the header | The header is what Databricks Apps injects; anything else is a different trust path |
| Forwarding the token but stripping `Authorization` and only sending `X-Forwarded-Access-Token` | Some downstream libraries only check `Authorization` — send both |
| Caching `generate_database_credential` results across requests | Tokens are user-bound — caching them across users leaks identity |

## Verification

After wiring OBO into a route:

1. `curl -H "X-Forwarded-Access-Token: <user-1-token>" .../patients/me` returns user-1's data.
2. `curl -H "X-Forwarded-Access-Token: <user-2-token>" .../patients/me` returns user-2's data, not user-1's.
3. `curl .../patients/me` (no header) returns 401.
4. In Lakebase, `SELECT current_user;` from the route returns the calling user's email, not the app's service principal email. Add a debug route that echoes `current_user` to confirm during initial wiring.

## Pointer

Full canonical code (`db.py`, `auth.py`, BFF client) is in [`references/canonical-patterns.md`](references/canonical-patterns.md). Copy from there into the service; do not invent new shapes.

