# Auth

> APIKeyAuth and JWTAuth for the agent HTTP server, with per-subject ownership.

Source: https://docs.pyrula.io/agents/serving/auth/

Two providers ship: `APIKeyAuth` (static bearer keys, each with a name that becomes the turn owner; for local dev, scripts and service-to-service callers) and `JWTAuth` (OIDC discovery, an explicit JWKS endpoint, or a local HS256 secret). Both ship with `pip install "pyrula[agents]"`.

## OIDC

```python
from pyrula.agents import create_app, AppOptions
from pyrula.workflows.web import JWTAuth

app = create_app(
    agents=[my_agent],
    store=store,
    llm=llm,
    options=AppOptions(
        auth=JWTAuth.from_oidc(
            issuer="https://accounts.example.com",
            audience="pyrula-api",
        ),
    ),
)
```

`from_oidc` fetches `/.well-known/openid-configuration` at startup and rotates keys in the background. A key missing from the cache triggers one refresh before a 401.

## Constructors

- `JWTAuth.from_oidc(issuer, audience, *, leeway=60, required_scopes=())`: the token's `iss` must match `issuer` exactly. For Auth0, Okta, Cognito or any compliant IdP.
- `JWTAuth.from_jwks(jwks_uri, audience, *, issuer=None, leeway=60, required_scopes=())`: explicit key set; `issuer` is optional.
- `JWTAuth.hs256(secret, audience, *, issuer=None, leeway=60, required_scopes=())`: local shared secret, no network.

`from_oidc` / `from_jwks` also accept `algorithms`, `allow_insecure_http`, `http_timeout`, `jwks_cache_ttl`, `jwks_min_refresh_interval`, `scope_claims` and `subject_claim`.

## Per-subject ownership

The `sub` claim is the turn owner: that subject can read, stream and cancel its turns. Anyone else gets 404, same as a missing id. To use a tenant id or allow an admin scope:

```python
from pyrula.contracts.web import AuthConfig

AuthConfig(
    provider=JWTAuth.from_oidc(...),
    principal_to_owner=lambda p: p.claims.get("org_id", p.subject),
    authorize_run=lambda p, owner: str(p) == owner or "admin" in p.scopes,
)
```

`Principal` exposes `.subject`, `.claims`, `.scopes` and `.token`. `.token` is the live credential: don't log or persist it in a custom `deps_factory` or `principal_to_owner`. Under the default mapping the router stores only `.subject`. `repr(principal)` leaves out `.token` and `.claims`.

## Scopes

```python
JWTAuth.from_oidc(
    issuer="https://accounts.example.com",
    audience="pyrula-api",
    required_scopes=["agents:write"],
)
```

Scopes are read from `scope` (space-separated), `scopes` or `permissions`. A token missing any required scope gets 403.

## Security properties

- The token's `alg` header never selects the verifier: JWKS resolvers pin RS256/ES256/PS256 by key type, and HS256 uses only the local secret.
- `audience` is required; a `None` or empty value is rejected, as is a token without a matching `aud`.
- `from_oidc` and `from_jwks` refuse non-localhost `http://` URIs.
- Key refresh is throttled to once per 30 seconds.
- If the JWKS endpoint is down and no keys are cached, the server returns 503, not 401.
- Under the default mapping only the `sub` value is persisted; raw tokens and other claims never reach the log. The owner stays in the runtime: it is never sent to Pyrula Cloud, and the events endpoints leave it out.

## API key

Give `APIKeyAuth` one key per caller, keyed by a name:

```python
import os

from pyrula.agents import APIKeyAuth

options = AppOptions(
    auth=APIKeyAuth({"ci": os.environ["CI_API_KEY"], "ops": os.environ["OPS_API_KEY"]}),
)
```

The caller's identity is the matching key's name, never the key. The name is the turn and thread owner, so `ci` cannot read a turn `ops` submitted. To rotate a key, replace its value under the same name: turns submitted with the old value stay readable with the new one, and the old value is refused. A single string, `APIKeyAuth("dev-key")`, is shorthand for `{"api-key": "dev-key"}`. A name must be non-empty and contain neither `:` nor `\x1f`; an empty key, or two names sharing one key, is refused at construction. The mapping is name to key: `{key: "ci"}` would make the key the name, and so the stored owner. A missing header gets a 401 with `auth_missing` and a wrong key a 401 with `auth_invalid`; neither says which names exist.

Both providers are FastAPI dependencies returning the caller's identity (or raising 401/403). A custom provider is any such callable; there is no base class.

## Running without auth

With no provider every route is open to anyone who can reach the port. The server allows it but not quietly:

- `create_app` and a directly mounted `AgentRouter` log a `No auth configured` warning at build time.
- `create_app(options=AppOptions(host=...))` raises `ConfigurationError` for a non-loopback host with no auth, unless `allow_unauthenticated=True`. It only sees a `host` you declare in `AppOptions`, not the one you later give uvicorn.
- The first request from a non-loopback address to a router with no auth setting logs one `ERROR` per process. It never refuses the request, because a proxy or Docker port map makes the peer address unreliable.
- To run open on purpose, pass `AppOptions(auth=None)` (or `AuthConfig(provider=None)`), which skips the warning.
