Skip to content
Pyrula

Auth

The agent HTTP server ships two auth providers: APIKeyAuth (a single static bearer token, useful for local dev and simple scripts) and JWTAuth (JWT verification backed by OIDC discovery, an explicit JWKS endpoint, or a local HS256 secret).

JWTAuth depends on PyJWT and httpx, pulled in by the [auth] extra:

Terminal window
pip install 'pyrula-agents[auth]'

Pass a JWTAuth provider through AppOptions:

from pyrula.agents import create_app, AppOptions
from pyrula.workflows.web.jwt_auth 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, extracts the JWKS URI, and rotates keys in the background. Tokens signed by any key currently in the JWKS are accepted; a key not found in the local cache triggers one immediate refresh before returning 401.

JWTAuth.from_oidc(issuer, audience, *, leeway=60, required_scopes=())

Section titled “JWTAuth.from_oidc(issuer, audience, *, leeway=60, required_scopes=())”

OIDC discovery. Fetches the provider metadata document and derives the JWKS URI from it. The issuer claim in the token must match the issuer argument exactly. Use this for Auth0, Okta, Cognito, or any standards-compliant IdP.

JWTAuth.from_jwks(jwks_uri, audience, *, issuer=None, leeway=60, required_scopes=())

Section titled “JWTAuth.from_jwks(jwks_uri, audience, *, issuer=None, leeway=60, required_scopes=())”

Explicit JWKS endpoint. Use when the discovery document is unavailable or you want to point directly at a key set. issuer validation is optional; omit it to skip the iss claim check.

JWTAuth.hs256(secret, audience, *, leeway=60, required_scopes=())

Section titled “JWTAuth.hs256(secret, audience, *, leeway=60, required_scopes=())”

Symmetric HMAC-SHA256 with a local secret. No network calls. Intended for internal services and testing where a shared secret is acceptable. PyJWT emits a warning if the HS256 secret is shorter than 32 bytes; use at least 32 bytes in production.

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

By default, the sub claim becomes the turn owner. A user who submits a turn under their sub can read, stream, and cancel it; any other subject gets 403.

This is zero-config: JWTAuth.__call__ returns a Principal whose __str__ is the sub value, and the router’s default authorize_run checks str(principal) == owner.

To override — for example, to use a tenant ID instead of sub, or to allow an admin scope to read any turn:

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 (the sub claim), .claims (the full decoded payload), .scopes (a set of strings derived from the token), and .token (the raw JWT string). .token is the live credential: a custom deps_factory or principal_to_owner receives the Principal, so do not log it or persist it. The router itself stores only .subject.

Pass required_scopes to require that the token carry all listed scopes before the request is admitted:

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

Scope claims are read from scope (space-separated string), scopes (list), or permissions (list), whichever is present. A token missing any required scope gets a 403.

  • Algorithm confusion is impossible by construction. JWKS resolvers pin to RS256/ES256/PS256 based on the key type; HS256 uses a local secret only. The alg field in the token header is never trusted to select the verifier.
  • Audience is required. JWTAuth.__init__ rejects a None or empty audience. Tokens without a matching aud claim are rejected.
  • HTTPS enforced. from_oidc and from_jwks refuse http:// URIs (non-localhost).
  • JWKS rotation is throttled. At most one key-refresh per 30 seconds; a missing key triggers a single immediate refresh before 401, not a refresh loop.
  • Fail-soft on key provider outage. If the JWKS endpoint is unreachable and no cached keys exist, the server returns 503 (service unavailable) rather than 401 (unauthorized), so clients can distinguish an auth failure from an infrastructure problem.
  • No claims in the durable log. The router writes only the sub value (via the owner seam) to the turn store. Raw token bytes and other claims are never persisted.

For local dev and scripts, APIKeyAuth remains the simplest option:

from pyrula.agents import APIKeyAuth
options = AppOptions(auth=APIKeyAuth(keys=["dev-key"]))

APIKeyAuth and JWTAuth implement the same AuthProvider ABC, so they are drop-in substitutes in AppOptions or AuthConfig.